gmal-scope-builder/backend/app/services/match_refiner.py
DJP bc778ce7af P2: Iterative prompting + RFP brief analysis engine
Iterative Prompting:
- Chat box on Match Review tab for natural language refinement
- "re-run under 70%" / "ignore zero volume" / "set all volumes to 1"
- Claude interprets instruction into structured actions
- Actions: rematch_below_threshold, rematch_specific, delete_assets, set_volume
- Re-matches affected assets automatically after refinement
- Chat log shows instruction history

RFP/Brief Analysis:
- New "Brief Analysis" tab between Upload and Match Review
- Extracts: summary, objectives, KPIs, channels, audiences, deliverable categories,
  constraints, timeline, budget, complexity assessment
- Generates prioritized discovery questions (Red/Amber/Green)
- Questions include category, rationale, and priority level
- Stored as JSON in project.brief_analysis field
- Uploaded files now saved to data dir for re-analysis
- Re-analyze button to refresh analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:15:31 -04:00

198 lines
7.7 KiB
Python

"""Interpret natural language instructions to refine matches."""
import logging
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match, MatchConfidence
from app.utils.claude_client import call_claude, extract_tool_result
logger = logging.getLogger(__name__)
REFINE_TOOL = {
"name": "execute_refinement",
"description": "Execute the user's refinement instruction on the matching results.",
"input_schema": {
"type": "object",
"properties": {
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["rematch_below_threshold", "rematch_specific", "delete_assets", "set_volume", "split_asset", "message"],
"description": "The action to take"
},
"threshold": {
"type": "number",
"description": "For rematch_below_threshold: the confidence threshold (0-1)"
},
"asset_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Client asset IDs to act on"
},
"hint": {
"type": "string",
"description": "For rematch_specific: hint to guide the re-match (e.g. 'this is a video not static')"
},
"volume": {
"type": "integer",
"description": "For set_volume: new volume value"
},
"message": {
"type": "string",
"description": "For message: response text to show the user"
},
},
"required": ["action"],
},
},
},
"required": ["actions"],
},
}
SYSTEM_PROMPT = """You are a helper that interprets user instructions about creative asset matching results.
The user has a list of client assets matched to GMAL production assets. They want to refine the results.
You will receive the current state of matches and the user's instruction. Translate the instruction into structured actions.
Available actions:
- rematch_below_threshold: Re-run AI matching for all assets below a confidence threshold. Set "threshold" (0-1).
- rematch_specific: Re-match specific assets with a hint. Set "asset_ids" and "hint".
- delete_assets: Remove client assets (e.g. "ignore zero volume items"). Set "asset_ids".
- set_volume: Change volume for assets. Set "asset_ids" and "volume".
- message: If the instruction is unclear or you need to explain something. Set "message".
When the user says things like:
- "re-run anything under 70%" → rematch_below_threshold with threshold 0.7
- "ignore all the zero volume ones" → delete_assets for those with volume 0
- "this should be a video" → rematch_specific for the referenced asset with hint
- "set all volumes to 1" → set_volume for all assets
Always include a "message" action at the end summarizing what you're doing."""
async def refine_matches(
db: AsyncSession,
project_id: int,
instruction: str,
) -> dict:
"""Interpret a user instruction and apply refinements to the project's matches."""
# Load current state
assets_result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project_id).order_by(ClientAsset.sort_order)
)
assets = assets_result.scalars().all()
matches_result = await db.execute(
select(Match, GmalAsset)
.join(GmalAsset, Match.gmal_asset_id == GmalAsset.id)
.where(Match.client_asset_id.in_([a.id for a in assets]))
.order_by(Match.client_asset_id, Match.rank)
)
matches_data = matches_result.all()
# Build context for Claude
state_lines = []
for a in assets:
asset_matches = [(m, g) for m, g in matches_data if m.client_asset_id == a.id]
selected = next((m for m, g in asset_matches if m.is_selected), None)
if selected:
gmal = next((g for m, g in asset_matches if m.id == selected.id), None)
score = float(selected.confidence_score or 0)
state_lines.append(
f" Asset ID {a.id}: \"{a.raw_name}\" (vol:{a.volume}) → {gmal.gmal_id if gmal else '?'} "
f"({selected.confidence.value} {score:.0%}) {'SELECTED' if selected.is_selected else ''}"
)
else:
state_lines.append(f" Asset ID {a.id}: \"{a.raw_name}\" (vol:{a.volume}) → NO MATCH SELECTED")
state_text = "\n".join(state_lines)
user_msg = f"""Current matching state ({len(assets)} assets):
{state_text}
User instruction: {instruction}"""
response = call_claude(
system=SYSTEM_PROMPT,
user_message=user_msg,
tools=[REFINE_TOOL],
tool_choice={"type": "tool", "name": "execute_refinement"},
max_tokens=2048,
)
result = extract_tool_result(response)
if not result or "actions" not in result:
return {"message": "Could not interpret the instruction.", "actions_taken": 0}
actions_taken = 0
messages = []
rematch_ids = []
for action in result["actions"]:
act = action["action"]
if act == "rematch_below_threshold":
threshold = action.get("threshold", 0.7)
# Find assets with selected match below threshold
for a in assets:
asset_matches = [(m, g) for m, g in matches_data if m.client_asset_id == a.id]
selected = next((m for m, g in asset_matches if m.is_selected), None)
if selected and float(selected.confidence_score or 0) < threshold:
rematch_ids.append(a.id)
elif not selected:
rematch_ids.append(a.id)
actions_taken += 1
elif act == "rematch_specific":
ids = action.get("asset_ids", [])
rematch_ids.extend(ids)
actions_taken += 1
elif act == "delete_assets":
ids = action.get("asset_ids", [])
if ids:
for aid in ids:
del_result = await db.execute(select(ClientAsset).where(ClientAsset.id == aid))
ca = del_result.scalar_one_or_none()
if ca:
await db.delete(ca)
actions_taken += 1
elif act == "set_volume":
ids = action.get("asset_ids", [])
vol = action.get("volume", 1)
for aid in ids:
vol_result = await db.execute(select(ClientAsset).where(ClientAsset.id == aid))
ca = vol_result.scalar_one_or_none()
if ca:
ca.volume = vol
actions_taken += 1
elif act == "message":
messages.append(action.get("message", ""))
# Clear existing matches for assets to be re-matched
if rematch_ids:
for aid in set(rematch_ids):
existing = await db.execute(select(Match).where(Match.client_asset_id == aid))
for m in existing.scalars().all():
await db.delete(m)
await db.commit()
return {
"message": " ".join(messages) if messages else f"Applied {actions_taken} actions.",
"actions_taken": actions_taken,
"rematch_asset_ids": list(set(rematch_ids)),
"rematch_count": len(set(rematch_ids)),
}