Improve matching, upload UX, collapse fix, full catalog approach

- Upload now shows live stage progress (uploading -> extracting -> AI parsing -> done)
- Fix match group collapse: proper React state instead of DOM manipulation
- Replace pre-filter with full GMAL catalog sent to Claude (~3k tokens, <$0.01)
  - FTS and keyword matching missed too many semantic matches
  - Claude now sees all 243 assets and uses semantic understanding
- Improved system prompt with terminology bridges for better scoring
- Per-project AI cost tracking persisted to DB
- Parallel matching with cancel support
- Auto-select matches >= 80%, YOLO button for rest
- Debug panel for AI call inspection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-27 19:22:08 -04:00
parent 23f3eaf3c8
commit 26d3435be0
10 changed files with 209 additions and 131 deletions

View file

@ -10,7 +10,7 @@ from app.database import get_db
from app.models.gmal import GmalAsset
from app.models.project import Project, ClientAsset, Match, ProjectStatus, MatchConfidence
from app.schemas.project import ClientAssetOut, ClientAssetUpdate, MatchOut, MatchSelectRequest, ManualMatchRequest
from app.services.doc_parser import parse_uploaded_file
from app.services.doc_parser import extract_text_from_file, parse_text_with_ai
from app.services.ai_matching import match_client_assets
router = APIRouter()
@ -26,25 +26,52 @@ async def upload_client_document(
"""Upload a client document and extract assets using AI."""
project = await _get_project(project_id, db)
# Read file
# Stage 1: Uploading
content = await file.read()
project.source_filename = file.filename
project.status = ProjectStatus.PARSING
project.status = ProjectStatus.UPLOADING
project.parse_stage = f"Uploading {file.filename}..."
await db.commit()
# Stage 2: Extracting text
project.status = ProjectStatus.EXTRACTING
project.parse_stage = "Extracting text from document..."
await db.commit()
# Parse document to extract assets
try:
extracted, usage_info = parse_uploaded_file(content, file.filename)
text, metadata = extract_text_from_file(content, file.filename)
except Exception as e:
logger.error(f"Document parsing failed: {e}")
project.status = ProjectStatus.DRAFT
project.parse_stage = None
await db.commit()
raise HTTPException(status_code=400, detail=f"Failed to extract text: {str(e)}")
sheets_info = f" ({metadata['sheet_count']} sheets)" if metadata['sheet_count'] else ""
project.parse_stage = f"Extracted {metadata['char_count']:,} characters{sheets_info}. Sending to AI..."
project.status = ProjectStatus.PARSING
await db.commit()
# Stage 3: AI parsing
try:
extracted, usage_info = parse_text_with_ai(text)
except Exception as e:
logger.error(f"AI parsing failed: {e}")
project.status = ProjectStatus.DRAFT
project.parse_stage = None
await db.commit()
raise HTTPException(status_code=400, detail=f"Failed to parse document: {str(e)}")
# Save AI costs to project
# Save AI costs
project.ai_input_tokens = (project.ai_input_tokens or 0) + usage_info.get("input_tokens", 0)
project.ai_output_tokens = (project.ai_output_tokens or 0) + usage_info.get("output_tokens", 0)
project.ai_cost_usd = float(project.ai_cost_usd or 0) + usage_info.get("cost_usd", 0)
project.ai_call_count = (project.ai_call_count or 0) + 1
# Clear existing client assets for this project
# Stage 4: Saving results
project.parse_stage = f"AI found {len(extracted)} assets. Saving..."
await db.commit()
# Clear existing client assets
existing = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project_id)
)
@ -65,6 +92,7 @@ async def upload_client_document(
assets.append(ca)
project.status = ProjectStatus.REVIEW
project.parse_stage = f"Done! {len(assets)} assets extracted."
await db.commit()
return {

View file

@ -97,6 +97,7 @@ def _project_out(project: Project, asset_count: int) -> ProjectOut:
model_type=project.model_type.value,
status=project.status.value,
source_filename=project.source_filename,
parse_stage=project.parse_stage,
ai_input_tokens=project.ai_input_tokens or 0,
ai_output_tokens=project.ai_output_tokens or 0,
ai_cost_usd=float(project.ai_cost_usd or 0),

View file

@ -10,6 +10,8 @@ from app.models.gmal import ModelType
class ProjectStatus(str, enum.Enum):
DRAFT = "draft"
UPLOADING = "uploading"
EXTRACTING = "extracting"
PARSING = "parsing"
MATCHING = "matching"
REVIEW = "review"
@ -34,6 +36,7 @@ class Project(Base):
model_type: Mapped[ModelType] = mapped_column(Enum(ModelType), default=ModelType.CURRENT_OPLUS)
status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.DRAFT)
source_filename: Mapped[str | None] = mapped_column(String(255))
parse_stage: Mapped[str | None] = mapped_column(String(255))
ai_input_tokens: Mapped[int] = mapped_column(Integer, default=0)
ai_output_tokens: Mapped[int] = mapped_column(Integer, default=0)
ai_cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0)

View file

@ -24,6 +24,7 @@ class ProjectOut(BaseModel):
model_type: str
status: str
source_filename: str | None
parse_stage: str | None = None
ai_input_tokens: int = 0
ai_output_tokens: int = 0
ai_cost_usd: float = 0

View file

@ -6,7 +6,7 @@ import threading
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import select
from sqlalchemy import select, text, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalAsset
@ -86,24 +86,28 @@ SYSTEM_PROMPT = """You are a GMAL asset matching specialist for a creative produ
Your job is to match client-described assets/deliverables to the closest equivalent(s) in the GMAL catalog.
The GMAL catalog is a standardized list of creative production assets, each with:
- A unique GMAL ID (e.g., GMAL101)
- Asset name and description
- Complexity level (Simple=1, Medium=2, Complex=3)
- Detailed complexity description
You are given the FULL GMAL catalog. Each entry has: GMAL ID | Asset Name | Complexity | Category.
Guidelines:
- Match based on the TYPE of deliverable first, then complexity level.
- Consider that clients may use different terminology (e.g., "banner" vs "web banner", "copywriting" vs "editorial").
- Clients use different terminology than GMAL. Use your understanding of creative production to bridge the gap:
- "Key Visual" / "KV" = Photography/Key Visual GMALs
- "PDP copy" / "product listing" = Copywriting/eCommerce GMALs
- "Launch video" / "hero video" = Campaign Video/TVC GMALs
- "Presentation deck" / "toolbox" = Presentation GMALs
- "Display banner" / "digital ad" = Standard Banner/Display GMALs
- "Social post" / "social content" = Social Content/Social Video GMALs
- "BTS" / "behind the scenes" = Behind The Scenes GMALs
- If the client asset maps clearly to one GMAL, set confidence="exact" with score 0.9-1.0.
- If similar but with notable differences, set confidence="close" with score 0.6-0.89.
- If multiple GMALs could match, return up to 3 ranked options with confidence="multiple".
- If nothing matches well, return the closest option with confidence="none" and score below 0.3.
- Always explain caveats: what the GMAL includes/excludes vs what the client described.
- Pay attention to complexity: a "simple banner" should match a Simple complexity GMAL, not Complex."""
- Pay attention to complexity: a "simple banner" should match a Simple complexity GMAL, not Complex.
- Be generous with scoring when the match is semantically correct even if the naming differs."""
def _match_single_asset(client_asset_name, client_asset_desc, volume, candidates_text, num_candidates):
def _match_single_asset(client_asset_name, client_asset_desc, volume, catalog_text, num_assets):
"""Run a single match call to Claude (synchronous, for use in thread pool)."""
user_msg = f"""Match this client asset to the best GMAL equivalent(s):
@ -112,8 +116,8 @@ Name: {client_asset_name}
Description: {client_asset_desc or 'No description provided'}
Volume: {volume}
GMAL CATALOG CANDIDATES ({num_candidates} assets):
{candidates_text}"""
FULL GMAL CATALOG ({num_assets} assets):
{catalog_text}"""
response = call_claude(
system=SYSTEM_PROMPT,
@ -138,13 +142,17 @@ async def match_client_assets(
"""
_clear_cancel(project_id)
# Load all GMAL assets for candidate selection
# Load all GMAL assets - send full compact catalog to Claude (only ~3k tokens)
result = await db.execute(
select(GmalAsset).where(GmalAsset.has_hour_routes == True)
select(GmalAsset).where(GmalAsset.has_hour_routes == True).order_by(GmalAsset.gmal_id)
)
all_gmals = result.scalars().all()
gmal_by_id = {g.gmal_id: g for g in all_gmals}
# Build compact catalog once - reused for every match call
catalog_text = _format_compact_catalog(all_gmals)
logger.info(f"Full GMAL catalog: {len(all_gmals)} assets, ~{len(catalog_text)} chars")
all_matches = []
total = len(client_assets)
@ -158,18 +166,11 @@ async def match_client_assets(
batch_num = batch_start // BATCH_SIZE + 1
logger.info(f"Matching batch {batch_num} ({batch_start+1}-{min(batch_start+BATCH_SIZE, total)} of {total})")
# Prepare all calls for this batch
call_args = []
for ca in batch:
candidates = _prefilter_candidates(ca, all_gmals)
candidates_text = _format_candidates(candidates)
call_args.append((ca, candidates, candidates_text))
# Run batch in parallel using thread pool
loop = asyncio.get_event_loop()
with ThreadPoolExecutor(max_workers=BATCH_SIZE) as executor:
futures = []
for ca, candidates, candidates_text in call_args:
for ca in batch:
if _is_cancelled(project_id):
break
future = loop.run_in_executor(
@ -178,8 +179,8 @@ async def match_client_assets(
ca.raw_name,
ca.raw_description,
ca.volume,
candidates_text,
len(candidates),
catalog_text,
len(all_gmals),
)
futures.append((ca, future))
@ -241,60 +242,18 @@ async def match_client_assets(
return all_matches
def _prefilter_candidates(client_asset: ClientAsset, all_gmals: list[GmalAsset], max_candidates: int = 25) -> list[GmalAsset]:
"""Pre-filter GMAL candidates using keyword overlap to reduce token usage."""
name = (client_asset.raw_name or "").lower()
desc = (client_asset.raw_description or "").lower()
search_text = f"{name} {desc}"
def _format_compact_catalog(all_gmals: list[GmalAsset]) -> str:
"""Format the full GMAL catalog as a compact list for Claude.
stop_words = {"the", "a", "an", "and", "or", "for", "to", "in", "of", "with", "is", "on", "at", "by"}
keywords = set(search_text.split()) - stop_words
~3k tokens for 243 assets. Much cheaper than pre-filtering and missing the right match.
"""
lines = []
current_cat = None
for g in sorted(all_gmals, key=lambda x: (x.sub_category or '', x.gmal_id)):
if g.sub_category != current_cat:
current_cat = g.sub_category
lines.append(f"\n[{current_cat}]")
complexity = g.complexity_name or f"L{g.complexity_level}"
lines.append(f" {g.gmal_id}: {g.unique_name or g.asset_name} ({complexity})")
scored = []
for gmal in all_gmals:
gmal_text = " ".join(filter(None, [
gmal.asset_name,
gmal.sub_category,
gmal.unique_name,
gmal.complexity_description,
gmal.ai_enhanced_description,
])).lower()
score = sum(1 for kw in keywords if kw in gmal_text)
if gmal.asset_name and any(word in gmal.asset_name.lower() for word in name.split() if len(word) > 3):
score += 5
scored.append((score, gmal))
scored.sort(key=lambda x: x[0], reverse=True)
candidates = [g for _, g in scored[:max_candidates]]
if len([s for s, _ in scored[:max_candidates] if s > 0]) < 5:
seen_cats = set()
for _, g in scored:
if g.sub_category not in seen_cats and g not in candidates:
candidates.append(g)
seen_cats.add(g.sub_category)
if len(candidates) >= max_candidates:
break
return candidates
def _format_candidates(candidates: list[GmalAsset]) -> str:
"""Format GMAL candidates as text for the Claude prompt."""
parts = []
for g in candidates:
desc = g.ai_enhanced_description or g.complexity_description or g.asset_description or ""
if len(desc) > 300:
desc = desc[:300] + "..."
parts.append(
f"- {g.gmal_id}: {g.unique_name or g.asset_name} "
f"(Complexity: {g.complexity_name or g.complexity_level}, "
f"Category: {g.sub_category})\n"
f" Description: {desc}"
)
return "\n\n".join(parts)
return "\n".join(lines)

View file

@ -63,29 +63,41 @@ Be thorough - extract every distinct asset type mentioned. If the same asset app
Do NOT combine different asset types into one entry."""
def parse_uploaded_file(file_content: bytes, filename: str) -> list[dict]:
"""Parse a client document and extract assets using Claude.
Returns a list of dicts: [{name, description, complexity_hint, volume}, ...]
"""
def extract_text_from_file(file_content: bytes, filename: str) -> tuple[str, dict]:
"""Extract text from a file. Returns (text, metadata)."""
ext = Path(filename).suffix.lower()
if ext == ".docx":
text = _extract_docx_text(file_content)
sheet_count = 0
elif ext in (".xlsx", ".xls"):
text = _extract_excel_text(file_content)
wb = openpyxl.load_workbook(io.BytesIO(file_content), data_only=True)
sheet_count = len(wb.sheetnames)
elif ext == ".txt":
text = file_content.decode("utf-8", errors="replace")
sheet_count = 0
else:
raise ValueError(f"Unsupported file type: {ext}. Use .docx, .xlsx, or .txt")
if not text or len(text.strip()) < 20:
raise ValueError("Document appears to be empty or too short to extract assets from.")
metadata = {
"char_count": len(text),
"sheet_count": sheet_count,
"file_type": ext,
}
# Truncate very long documents to manage token usage
if len(text) > 50000:
text = text[:50000] + "\n\n[Document truncated...]"
return text, metadata
def parse_text_with_ai(text: str) -> tuple[list[dict], dict]:
"""Send extracted text to Claude to identify assets. Returns (assets, usage_info)."""
response = call_claude(
system=SYSTEM_PROMPT,
user_message=f"Extract all deliverable assets from this client document:\n\n{text}",
@ -94,7 +106,6 @@ def parse_uploaded_file(file_content: bytes, filename: str) -> list[dict]:
max_tokens=16000,
)
# Extract usage info for per-project tracking
usage_info = getattr(response, '_usage_info', {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0})
result = extract_tool_result(response)

View file

@ -61,6 +61,16 @@ def parse_gmal_workbook(filepath: str, db: Session) -> dict:
# Step 4: Load role-level mappings
result["role_mappings_loaded"] = _load_role_mappings(wb, db)
# Populate full-text search vectors
db.execute(text("""
UPDATE gmal_assets SET search_vector =
setweight(to_tsvector('english', coalesce(asset_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(unique_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(sub_category, '')), 'B') ||
setweight(to_tsvector('english', coalesce(asset_description, '')), 'C') ||
setweight(to_tsvector('english', coalesce(complexity_description, '')), 'C')
"""))
db.commit()
logger.info(f"Ingestion complete: {result}")
return result

View file

@ -129,10 +129,10 @@ def call_claude(
# Attach usage to response for callers to save per-project
response._usage_info = {"input_tokens": inp, "output_tokens": out, "cost_usd": cost}
logger.info(
f"Claude API call: {inp} in / {out} out tokens, "
f"${cost:.4f} this call, ${_usage['total_cost_usd']:.4f} total"
)
logger.info(
f"Claude API call: {inp} in / {out} out tokens, "
f"${cost:.4f} this call, ${_usage['total_cost_usd']:.4f} total"
)
return response

View file

@ -92,6 +92,37 @@
border-color: var(--color-text-muted);
}
.upload-active {
border-color: var(--color-primary);
background: rgba(255, 196, 7, 0.03);
}
.upload-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.upload-stage {
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.upload-icon {
color: var(--color-text-muted);
margin-bottom: 16px;
@ -235,14 +266,6 @@ span.conf-badge-sm.conf-none { background: var(--color-danger); }
font-style: italic;
}
.match-group-collapsed-body {
display: none;
}
.match-group-expanded .match-group-collapsed-body {
display: block;
}
.match-group-collapsed .match-group-header {
border-bottom: none;
}

View file

@ -23,8 +23,10 @@ export default function ProjectView() {
const [ratecard, setRatecard] = useState<RatecardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadStage, setUploadStage] = useState('');
const [matching, setMatching] = useState(false);
const [building, setBuilding] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const loadProject = useCallback(async () => {
try {
@ -62,6 +64,18 @@ export default function ProjectView() {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadStage(`Uploading ${file.name}...`);
// Poll project status for stage updates
const pollInterval = setInterval(async () => {
try {
const res = await api.get(`/projects/${id}`);
if (res.data.parse_stage) {
setUploadStage(res.data.parse_stage);
}
} catch {}
}, 1500);
try {
const form = new FormData();
form.append('file', file);
@ -71,7 +85,9 @@ export default function ProjectView() {
} catch (err: any) {
alert(`Upload failed: ${err.response?.data?.detail || err.message}`);
} finally {
clearInterval(pollInterval);
setUploading(false);
setUploadStage('');
}
}
@ -133,9 +149,15 @@ export default function ProjectView() {
}
}
async function handleSelectMatch(matchId: number) {
async function handleSelectMatch(matchId: number, clientAssetId: number) {
try {
await api.put(`/projects/${id}/matches/${matchId}/select`, { is_selected: true });
// Collapse the group after selection
setExpandedGroups(prev => {
const next = new Set(prev);
next.delete(clientAssetId);
return next;
});
await loadProject();
} catch (err: any) {
alert(`Failed: ${err.response?.data?.detail || err.message}`);
@ -206,20 +228,29 @@ export default function ProjectView() {
{tab === 'upload' && (
<div className="tab-content">
<div className="upload-zone">
<div className="upload-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p className="upload-title">Upload Client Document</p>
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
<label className="btn btn-primary upload-btn">
{uploading ? 'Uploading & Parsing...' : 'Choose File'}
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden disabled={uploading} />
</label>
{project.source_filename && (
<p className="upload-file">Current: {project.source_filename}</p>
<div className={`upload-zone ${uploading ? 'upload-active' : ''}`}>
{uploading ? (
<>
<div className="upload-spinner" />
<p className="upload-stage">{uploadStage}</p>
</>
) : (
<>
<div className="upload-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p className="upload-title">Upload Client Document</p>
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
<label className="btn btn-primary upload-btn">
Choose File
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden />
</label>
{project.source_filename && (
<p className="upload-file">Current: {project.source_filename}</p>
)}
</>
)}
</div>
@ -266,22 +297,31 @@ export default function ProjectView() {
{assets.map(a => {
const assetMatches = matchesByAsset[a.id] || [];
const selectedMatch = assetMatches.find(m => m.is_selected);
const isCollapsed = !!selectedMatch && (selectedMatch.confidence_score ?? 0) >= 0.8;
const hasSelected = !!selectedMatch;
const isExpanded = expandedGroups.has(a.id);
const showBody = !hasSelected || isExpanded;
function toggleGroup() {
if (!hasSelected) return;
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(a.id)) next.delete(a.id);
else next.add(a.id);
return next;
});
}
return (
<div key={a.id} className={`match-group ${isCollapsed ? 'match-group-collapsed' : ''}`}>
<div key={a.id} className={`match-group ${hasSelected && !isExpanded ? 'match-group-collapsed' : ''}`}>
<div
className="match-group-header"
onClick={() => {
// Toggle expand/collapse by toggling a CSS class
const el = document.getElementById(`match-group-${a.id}`);
el?.classList.toggle('match-group-expanded');
}}
style={{ cursor: isCollapsed ? 'pointer' : 'default' }}
onClick={toggleGroup}
style={{ cursor: hasSelected ? 'pointer' : 'default' }}
>
<div className="match-asset-info">
<div className="match-asset-name-row">
<span className="match-asset-name">{a.raw_name}</span>
{selectedMatch && (
{hasSelected && !isExpanded && (
<span className="match-selected-summary">
<span className={`conf-badge-sm ${CONF_CLASS[selectedMatch.confidence]}`}>
{Math.round((selectedMatch.confidence_score || 0) * 100)}%
@ -289,7 +329,7 @@ export default function ProjectView() {
{selectedMatch.gmal_id} - {selectedMatch.gmal_unique_name || selectedMatch.gmal_name}
</span>
)}
{isCollapsed && <span className="match-expand-hint">click to expand</span>}
{hasSelected && <span className="match-expand-hint">{isExpanded ? 'click to collapse' : 'click to expand'}</span>}
</div>
{a.raw_description && (
<span className="match-asset-desc">{a.raw_description}</span>
@ -298,7 +338,8 @@ export default function ProjectView() {
<span className="match-asset-vol">Vol: {a.volume}</span>
</div>
<div id={`match-group-${a.id}`} className={`match-group-body ${isCollapsed ? 'match-group-collapsed-body' : ''}`}>
{showBody && (
<div className="match-group-body">
{assetMatches.length === 0 ? (
<div className="match-empty">
No matches yet. Click "Run AI Matching" to find GMAL equivalents.
@ -318,7 +359,7 @@ export default function ProjectView() {
{m.confidence} {m.confidence_score ? `${Math.round(m.confidence_score * 100)}%` : ''}
</span>
{!m.is_selected ? (
<button onClick={(e) => { e.stopPropagation(); handleSelectMatch(m.id); }} className="btn-select">
<button onClick={(e) => { e.stopPropagation(); handleSelectMatch(m.id, a.id); }} className="btn-select">
Select
</button>
) : (
@ -341,6 +382,7 @@ export default function ProjectView() {
))
)}
</div>
)}
</div>
);
})}