Skip to content

Commit 113b764

Browse files
feat: detect + agent-resolve merged-bill duplicate events (#257)
* refactor(clean-duplicates): extract EventMergeHelper for pairwise merge reuse Pulls the trash+ticket-url forward-merge logic out of CleanDuplicatesCommand into a single helper so the upcoming merged-bill resolver (issue #256) can reuse the same primitive without copy-paste. Behavior is unchanged: same trash semantics, same ticket-URL merge rule (only fill when the winner has none and the loser has one). * feat(merged-bills): detector ability + CLI for merged-bill candidate scanning Adds the deterministic detection layer for issue #256: same venue + same start_datetime + different title pairs scored on lineup overlap. Detector logic (inc/Abilities/MergedBillDetectAbilities.php): - SQL groups upcoming published events by (venue_term_id, start_datetime) using the datamachine_event_dates and datamachine_post_identity indexes. - Pairwise scoring per issue #256: +5 mutual body-lineup mention (whole-word, 3-char min, both directions) +2 identical end_datetime +1 matching price (normalized) +1 matching source URL host - Pairs at or above the threshold (default 5 = mutual lineup minimum) are persisted to the datamachine_pending_actions queue with kind 'merged_bill_resolve'. The agent decision step drains the queue. - Already-decided pair_keys are skipped on subsequent runs. CLI surface (inc/Cli/Check/CheckMergedBillsCommand.php): - 'wp data-machine-events check merged-bills' mirrors CleanDuplicatesCommand. - Supports --dry-run, --threshold, --limit, --days-ahead, --format. Bootstrap wires the ability into the abilities registry. The chat tool, merge executor, and resolver flow are gated on class_exists() so they remain inert until their commits land later in this PR. Refs #256. * feat(abilities): merge_event_posts ability uses shared EventMergeHelper Adds 'data-machine-events/merge-event-posts' ability — the pairwise merge executor for the merged-bill resolver (issue #256). - Input: winner_post_id, loser_post_id, optional merge_ticket_url + reason. - Validates both posts are event post type so generic agents cannot misuse the ability to trash arbitrary posts. - Delegates to EventMergeHelper::merge() so behavior is identical to 'wp data-machine-events check clean-duplicates'. - Emits a datamachine_log 'info' entry for audit. Refs #256. * feat(merged-bills): merged-bill inspect + decide chat tools Adds the agent decision layer for issue #256: - inc/Abilities/MergedBillDecideAbilities.php Two abilities: 'merged-bill-inspect' (read-only post bodies + signals) and 'merged-bill-decide' (commit verdict). The decide ability handles merge / distinct / needs_human and writes the resolution into the datamachine_pending_actions row so subsequent detector runs skip the pair. - inc/Api/Chat/Tools/MergedBillInspect.php - inc/Api/Chat/Tools/MergedBillDecide.php Thin BaseTool wrappers that expose the abilities to the chat agent. Chat tool files live in inc/Api/Chat/Tools/ to follow the existing convention in this plugin (DataMachineEvents has no inc/Steps/<X>/Tool pattern — that path in the issue body was the architectural sketch, not the established convention). Decision contract per issue #256: - merge: requires winner_post_id; invokes the merge-event-posts ability and records resolution=accepted. - distinct: records resolution=rejected, no post mutations. - needs_human: records resolution=expired with verdict metadata so the detector treats it as decided-skip and the pair surfaces in operator pending-actions tooling for review. Refs #256. * feat(merged-bills): resolver flow + scoring tests + production fixtures Last piece of the issue #256 stack: - inc/Steps/MergedBills/MergedBillResolverFlow.php Action Scheduler wiring. Schedules a daily recurring detector run via as_schedule_recurring_action() (gated on AS availability so the CLI remains usable without AS). Fires 'datamachine_events_merged_bill_pair_queued' for each high-confidence pair so external orchestrators (chat agents, pipelines) can drain the queue. Cadence configurable via 'datamachine_events_merged_bill_scan_interval' filter. - tests/Unit/EventMergeHelperTest.php Covers the shared trash + ticket_url forward-merge primitive extracted in the first commit. Invalid IDs, same winner/loser, missing posts, basic merge, ticket URL behavior (fill-when-empty / preserve / opt-out). - tests/Unit/MergedBillDetectScoringTest.php Covers the lineup-mention heuristic — the strongest signal in the scoring table. Asserts the two production repro pairs from issue #256 score as mutual mentions, asserts one-sided mentions and distinct lineups do not, asserts sub-3-char artist names are filtered. - tests/Fixtures/merged-bills/ Production post_content for the two repro pairs cited in issue #256 (5366/6504 Maraluso/Emma Grace, 12359/219469 Local Nomad/Babe Club), pulled live from events.extrachill.com. Refs #256. --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent b18e1dd commit 113b764

17 files changed

Lines changed: 1966 additions & 10 deletions

data-machine-events.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function datamachine_get_event_timing( int $post_id ): string {
118118
\WP_CLI::add_command( 'data-machine-events check duration', \DataMachineEvents\Cli\Check\CheckDurationCommand::class );
119119
\WP_CLI::add_command( 'data-machine-events check duplicates', \DataMachineEvents\Cli\Check\CheckDuplicatesCommand::class );
120120
\WP_CLI::add_command( 'data-machine-events check clean-duplicates', \DataMachineEvents\Cli\Check\CleanDuplicatesCommand::class );
121+
\WP_CLI::add_command( 'data-machine-events check merged-bills', \DataMachineEvents\Cli\Check\CheckMergedBillsCommand::class );
121122
\WP_CLI::add_command( 'data-machine-events check quality', \DataMachineEvents\Cli\Check\CheckQualityCommand::class );
122123
\WP_CLI::add_command( 'data-machine-events check all', \DataMachineEvents\Cli\Check\CheckAllCommand::class );
123124
}
@@ -438,6 +439,36 @@ function ( array $templates ): array {
438439
new \DataMachineEvents\Abilities\EventDateQueryAbilities();
439440
}
440441

442+
if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDetectAbilities.php' ) ) {
443+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDetectAbilities.php';
444+
new \DataMachineEvents\Abilities\MergedBillDetectAbilities();
445+
}
446+
447+
if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergeEventPostsAbilities.php' ) ) {
448+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergeEventPostsAbilities.php';
449+
new \DataMachineEvents\Abilities\MergeEventPostsAbilities();
450+
}
451+
452+
if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDecideAbilities.php' ) ) {
453+
require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/MergedBillDecideAbilities.php';
454+
new \DataMachineEvents\Abilities\MergedBillDecideAbilities();
455+
}
456+
457+
// Chat tools for the merged-bill agent decision step (issue #256).
458+
if ( class_exists( 'DataMachineEvents\\Api\\Chat\\Tools\\MergedBillInspect' ) ) {
459+
new \DataMachineEvents\Api\Chat\Tools\MergedBillInspect();
460+
}
461+
if ( class_exists( 'DataMachineEvents\\Api\\Chat\\Tools\\MergedBillDecide' ) ) {
462+
new \DataMachineEvents\Api\Chat\Tools\MergedBillDecide();
463+
}
464+
465+
// Recurring resolver hook — drains the merged-bill candidate queue
466+
// by invoking the chat agent. Registered at init so the hook fires
467+
// once Action Scheduler is ready.
468+
if ( class_exists( 'DataMachineEvents\\Steps\\MergedBills\\MergedBillResolverFlow' ) ) {
469+
\DataMachineEvents\Steps\MergedBills\MergedBillResolverFlow::register();
470+
}
471+
441472
$this->registerSystemHealthChecks();
442473
}
443474

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
/**
3+
* Merge Event Posts Ability
4+
*
5+
* Pairwise merge executor: given a winner and loser post ID, trashes the
6+
* loser and forward-merges its ticket URL into the winner when the winner
7+
* has none. Reuses the EventMergeHelper primitive shared with
8+
* CleanDuplicatesCommand so behavior is identical across operator-driven
9+
* cleanup and agent-driven merged-bill resolution.
10+
*
11+
* Callable from the chat tool path (merged_bill_decide) and the REST API.
12+
*
13+
* @package DataMachineEvents\Abilities
14+
* @since 0.34.0
15+
*/
16+
17+
namespace DataMachineEvents\Abilities;
18+
19+
use DataMachineEvents\Core\DuplicateDetection\EventMergeHelper;
20+
use DataMachineEvents\Core\Event_Post_Type;
21+
22+
defined( 'ABSPATH' ) || exit;
23+
24+
class MergeEventPostsAbilities {
25+
26+
private static bool $registered = false;
27+
28+
public function __construct() {
29+
if ( ! self::$registered ) {
30+
$this->registerAbility();
31+
self::$registered = true;
32+
}
33+
}
34+
35+
private function registerAbility(): void {
36+
$register_callback = function () {
37+
wp_register_ability(
38+
'data-machine-events/merge-event-posts',
39+
array(
40+
'label' => __( 'Merge event posts', 'data-machine-events' ),
41+
'description' => __( 'Merge a duplicate event pair: trashes the loser and forward-merges its ticket URL when the winner has none. Used by the merged-bill resolver and by clean-duplicates.', 'data-machine-events' ),
42+
'category' => 'datamachine-events-events',
43+
'input_schema' => array(
44+
'type' => 'object',
45+
'required' => array( 'winner_post_id', 'loser_post_id' ),
46+
'properties' => array(
47+
'winner_post_id' => array(
48+
'type' => 'integer',
49+
'description' => 'Post ID to keep. Must be an event post.',
50+
),
51+
'loser_post_id' => array(
52+
'type' => 'integer',
53+
'description' => 'Post ID to trash. Must be an event post.',
54+
),
55+
'merge_ticket_url' => array(
56+
'type' => 'boolean',
57+
'description' => 'Whether to forward-merge the ticket URL when the winner has none. Default true.',
58+
),
59+
'reason' => array(
60+
'type' => 'string',
61+
'description' => 'Free-form reason recorded in the merge log.',
62+
),
63+
),
64+
),
65+
'execute_callback' => array( $this, 'execute' ),
66+
'permission_callback' => function () {
67+
return current_user_can( 'manage_options' );
68+
},
69+
'meta' => array( 'show_in_rest' => true ),
70+
)
71+
);
72+
};
73+
74+
if ( did_action( 'wp_abilities_api_init' ) ) {
75+
$register_callback();
76+
} else {
77+
add_action( 'wp_abilities_api_init', $register_callback );
78+
}
79+
}
80+
81+
/**
82+
* Execute the merge.
83+
*
84+
* @param array $input {
85+
* @type int $winner_post_id Required.
86+
* @type int $loser_post_id Required.
87+
* @type bool $merge_ticket_url Default true.
88+
* @type string $reason Optional audit string.
89+
* }
90+
* @return array|\WP_Error
91+
*/
92+
public function execute( array $input ): array|\WP_Error {
93+
$winner_id = (int) ( $input['winner_post_id'] ?? 0 );
94+
$loser_id = (int) ( $input['loser_post_id'] ?? 0 );
95+
$merge_ticket_url = (bool) ( $input['merge_ticket_url'] ?? true );
96+
$reason = trim( (string) ( $input['reason'] ?? '' ) );
97+
98+
if ( $winner_id <= 0 || $loser_id <= 0 ) {
99+
return new \WP_Error(
100+
'invalid_input',
101+
'winner_post_id and loser_post_id are both required and must be positive integers.',
102+
array( 'status' => 400 )
103+
);
104+
}
105+
106+
// Both posts must be events. We guard here so a generic agent
107+
// invocation cannot misuse this ability to trash arbitrary posts.
108+
$winner = get_post( $winner_id );
109+
$loser = get_post( $loser_id );
110+
if ( ! $winner || ! $loser ) {
111+
return new \WP_Error( 'not_found', 'One or both posts do not exist.', array( 'status' => 404 ) );
112+
}
113+
if ( Event_Post_Type::POST_TYPE !== $winner->post_type || Event_Post_Type::POST_TYPE !== $loser->post_type ) {
114+
return new \WP_Error(
115+
'wrong_post_type',
116+
sprintf( 'Both posts must be of post_type "%s".', Event_Post_Type::POST_TYPE ),
117+
array( 'status' => 400 )
118+
);
119+
}
120+
121+
$merge_result = EventMergeHelper::merge(
122+
$winner_id,
123+
$loser_id,
124+
array( 'merge_ticket_url' => $merge_ticket_url )
125+
);
126+
127+
if ( ! $merge_result['success'] ) {
128+
return new \WP_Error(
129+
'merge_failed',
130+
$merge_result['error'] ?? 'Merge failed for an unknown reason.',
131+
array( 'status' => 500 )
132+
);
133+
}
134+
135+
do_action(
136+
'datamachine_log',
137+
'info',
138+
'Merged event posts.',
139+
array(
140+
'winner_id' => $winner_id,
141+
'loser_id' => $loser_id,
142+
'ticket_url_merged' => $merge_result['ticket_url_merged'],
143+
'reason' => $reason,
144+
)
145+
);
146+
147+
return array(
148+
'success' => true,
149+
'winner_id' => $winner_id,
150+
'loser_id' => $loser_id,
151+
'trashed' => $merge_result['trashed'],
152+
'ticket_url_merged' => $merge_result['ticket_url_merged'],
153+
);
154+
}
155+
}

0 commit comments

Comments
 (0)