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>
198 lines
7.7 KiB
Python
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)),
|
|
}
|