From 9825b0497cab73e47d0bb7d3e8a303cd58f39410 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 4 May 2026 16:12:47 -0400 Subject: [PATCH] Round 2 feedback: parser fix, dynamic max_tokens, polling, TM auto-discovery, reviewer comments in export A1 Export columns shifted (critical): - V25 LLM occasionally emits 12/13-col tables with Copy Type/Char Limit prefix - Parser now anchors on "Option 1" header position; robust to any prefix shift - Verified with 23/23 unit tests covering 11/12/13-col variants - Source-line block in prompt no longer uses pipe separators (defence in depth) A2 Linguistic summary fallback: - Drop the metadata key/value table fallback on Tab 2 - Show "No linguistic summary was generated" when the agent didn't produce one A3 Dashboard stuck on "Running": - useJobs / useJob now poll every 5s while any job/locale is in an active state - Stops polling once everything is COMPLETED or ERROR B1 TM auto-config: respect empty selection - Send no TM files when user unchecks all (was auto-adding campaign channel) - Backend distinguishes empty list vs missing field B2 Auto-discover channels from TM registry: - New GET /api/v1/files/tm/channels endpoint reads distinct channels from registry - Frontend StepConfigure fetches channels per client; falls back to static list - Pipeline TM resolution falls back to flat__.json pattern for any registered channel (no hardcoded map needed for new channels like PrimeCBM) B3 Job inputs visible on monitoring: - New "Inputs sent to the agent" card on /jobs/[id] showing AI model, TM files, supplementary file list, and context override - New GET /api/v1/jobs/{id}/supplementary endpoint listing on-disk supplementary files C1 Context cap (large briefs truncating): - max_tokens scales with source line count (8k/16k/32k/64k by tier) - 172-line briefs now have ~64k output budget instead of fixed 16k D1 Reviewer comments in xlsx export: - Export endpoint now copies xlsx to temp path on download, queries Feedback joined with User, and appends "Reviewer (Name): comment" to the rationale cells of options that have feedback - Original generated file remains untouched D2 Hide Clients & Voice from sidebar (page still reachable by URL) D3 Remove dead notifications + settings icons from header D4 Cost by Locale table added to Analytics with total + avg cost per brief Makefile seed target now also runs register_storage_files so TM registry is populated from disk on first setup (deploy.sh already does this via --init). Co-Authored-By: Claude Opus 4.6 --- Makefile | 1 + backend/app/api/v1/files.py | 35 ++++ backend/app/api/v1/jobs.py | 26 +++ backend/app/pipeline/agents/agent_single.py | 184 ++++++++++++------ backend/app/pipeline/modules/excel_writer.py | 68 +++---- backend/app/services/job_service.py | 9 +- backend/app/services/output_service.py | 96 ++++++++- backend/app/services/report_service.py | 17 +- backend/app/tasks/job_tasks.py | 9 +- frontend/src/app/admin/reports/page.tsx | 46 +++++ frontend/src/app/jobs/[jobId]/page.tsx | 68 ++++++- .../jobs/JobWizard/StepConfigure.tsx | 41 ++-- .../components/jobs/JobWizard/StepReview.tsx | 5 +- frontend/src/components/layout/AppShell.tsx | 13 +- frontend/src/components/layout/Sidebar.tsx | 6 +- frontend/src/hooks/useJobs.ts | 53 ++++- frontend/src/lib/api.ts | 25 +++ frontend/src/lib/types.ts | 2 + 18 files changed, 547 insertions(+), 157 deletions(-) diff --git a/Makefile b/Makefile index 728c537..2c21f7e 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ migrate: seed: docker compose exec backend python -m seed.create_default_client docker compose exec backend python -m seed.create_test_users + docker compose exec backend python -m seed.register_storage_files test: docker compose exec backend python -m pytest tests/ -v diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 2115917..6568a6e 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -68,6 +68,41 @@ async def list_tm_files( return [TMFileResponse.model_validate(f) for f in files] +@router.get("/tm/channels") +async def list_tm_channels( + client_id: UUID = Query(...), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +) -> dict: + """Return distinct channel names found in the TM registry, with the + locales each channel is available for. + + Used by the New Job wizard to populate the Channel and TM Files + selectors dynamically — adding a new TM file (e.g. flat_PrimeCBM_de-de.json) + causes the channel to appear here without code changes. + """ + from sqlalchemy import select + from app.models.files import TMFileRegistry + + result = await db.execute( + select(TMFileRegistry.channel, TMFileRegistry.locale_code) + .where(TMFileRegistry.client_id == client_id) + ) + rows = result.all() + + by_channel: dict[str, set[str]] = {} + for ch, lc in rows: + if not ch: + continue + by_channel.setdefault(ch, set()).add(lc) + + channels = sorted( + ({"name": ch, "locales": sorted(list(locs))} for ch, locs in by_channel.items()), + key=lambda c: c["name"].lower(), + ) + return {"channels": channels} + + @router.get("/tm/{file_id}/download") async def download_tm_file( file_id: UUID, diff --git a/backend/app/api/v1/jobs.py b/backend/app/api/v1/jobs.py index 0d2277a..ccbaf46 100644 --- a/backend/app/api/v1/jobs.py +++ b/backend/app/api/v1/jobs.py @@ -262,6 +262,32 @@ async def upload_supplementary( return {"message": "File uploaded", "file_path": file_path} +@router.get("/{job_id}/supplementary") +async def list_supplementary( + job_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +) -> dict: + """List supplementary file names uploaded for a job.""" + import os + from app.config import settings + + job = await job_service.get_job(db, job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + + supp_dir = os.path.join( + settings.STORAGE_ROOT, "jobs", str(job_id), "supplementary" + ) + files: list[str] = [] + if os.path.isdir(supp_dir): + files = sorted( + f for f in os.listdir(supp_dir) + if os.path.isfile(os.path.join(supp_dir, f)) + ) + return {"files": files} + + @router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_job( job_id: UUID, diff --git a/backend/app/pipeline/agents/agent_single.py b/backend/app/pipeline/agents/agent_single.py index c944964..69675ef 100644 --- a/backend/app/pipeline/agents/agent_single.py +++ b/backend/app/pipeline/agents/agent_single.py @@ -29,6 +29,13 @@ from app.pipeline.agents.agent_2_tm_retrieval import ( CHANNEL_FILE_MAP, _load_all_tm_entries, ) + +# Pattern used to resolve TM file paths for ANY channel — supports +# auto-discovered channels from the registry (e.g. PrimeCBM) without +# requiring a hardcoded map entry. Files are named +# `flat__.json` on disk; if the file doesn't exist for a +# given (channel, locale) pair the pipeline silently skips it. +TM_FILENAME_PATTERN = "flat_{channel}_{lc}.json" from app.pipeline.modules.ref_file_loader import load_all_reference_files logger = logging.getLogger(__name__) @@ -47,17 +54,26 @@ def _resolve_all_tm_paths( locale_code: str, tm_channels: list[str], ) -> list[str]: - """Resolve TM file paths for multiple channels.""" + """Resolve TM file paths for multiple channels. + + Tries the legacy hardcoded CHANNEL_FILE_MAP first (which has the + canonical lowercase patterns for the original channels). For any + channel not in the map (e.g. newly registered like PrimeCBM), falls + back to the generic `flat__.json` pattern. + Missing files at the resolved path are silently skipped at load time. + """ lc = locale_code.lower() tm_dir = os.path.join(settings.STORAGE_ROOT, "amazon", "tm", locale_code) paths: list[str] = [] for ch in tm_channels: pattern = CHANNEL_FILE_MAP.get(ch.lower()) - if not pattern: - logger.warning("Unknown TM channel %r; skipping", ch) - continue - filename = pattern.format(lc=lc) + if pattern: + filename = pattern.format(lc=lc) + else: + # Auto-discovered channel — use the generic pattern with the + # channel name as-cased in the registry. + filename = TM_FILENAME_PATTERN.format(channel=ch, lc=lc) path = os.path.join(tm_dir, filename) if path not in paths: paths.append(path) @@ -66,7 +82,12 @@ def _resolve_all_tm_paths( def _format_source_lines_for_prompt(source_lines: list[dict[str, Any]]) -> str: - """Format source lines into a structured block for the user message.""" + """Format source lines into a structured block for the user message. + + Avoid pipe separators in the per-line metadata: pipes can cue the LLM + into mirroring those fields as extra columns in the output markdown + table, which historically caused column-shift bugs in the export. + """ lines = ["## SOURCE LINES", ""] for i, sl in enumerate(source_lines, 1): en = sl.get("en_gb", "") @@ -74,14 +95,13 @@ def _format_source_lines_for_prompt(source_lines: list[dict[str, Any]]) -> str: char_limit = sl.get("char_limit", "") guidance = sl.get("creative_guidance", "") - line = f"[{i}] EN_GB: {en}" + lines.append(f"[{i}] EN_GB: {en}") if copy_type: - line += f" | Copy Type: {copy_type}" + lines.append(f" Copy Type: {copy_type}") if char_limit: - line += f" | Char Limit: {char_limit}" + lines.append(f" Char Limit: {char_limit}") if guidance: - line += f" | Guidance: {guidance}" - lines.append(line) + lines.append(f" Guidance: {guidance}") return "\n".join(lines) @@ -141,15 +161,42 @@ def _format_ref_data_for_prompt(ref_data: dict[str, Any]) -> str: # Markdown table parser # --------------------------------------------------------------------------- -def _parse_markdown_table(response_text: str) -> tuple[list[list[str]], str]: - """Parse the V25 markdown table output into structured rows. +def _split_row(line: str) -> list[str]: + """Split a markdown table row, preserving empty cells.""" + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return [c.strip() for c in stripped.split("|")] + + +def _find_option1_index(headers: list[str]) -> int | None: + """Locate the column index of the 'Option 1' header. + + The V25 LLM occasionally emits extra metadata columns (e.g. Copy Type, + Char Limit) before the option triplets, shifting later columns. By + anchoring on the Option 1 position we extract the 9 option/BT/rationale + cells reliably regardless of how many prefix columns the model adds. + """ + for i, h in enumerate(headers): + normalised = h.lower().strip() + if normalised in ("option 1", "option1") or normalised.startswith("option 1 "): + return i + return None + + +def _parse_markdown_table( + response_text: str, +) -> tuple[list[str], list[list[str]], str]: + """Parse the V25 markdown table output. Returns: - Tuple of (parsed_rows, linguistic_summary). - Each row is a list of cell strings in column order. + Tuple of (headers, parsed_rows, linguistic_summary). + headers is the list of column header strings (in order). + Each row in parsed_rows is a list of cell strings. We use lists (not dicts) because the V25 table has duplicate column names (Backtranslation x3, Rationale x3). - linguistic_summary is any text after the table. """ lines = response_text.split("\n") table_lines: list[str] = [] @@ -163,9 +210,7 @@ def _parse_markdown_table(response_text: str) -> tuple[list[list[str]], str]: table_started = True table_lines.append(stripped) elif table_started and not stripped.startswith("|"): - # Check if this is a separator line or empty line within the table if stripped == "" and not table_ended: - # Could be a gap — check if next lines have table content continue table_ended = True post_table_lines.append(line) @@ -174,65 +219,75 @@ def _parse_markdown_table(response_text: str) -> tuple[list[list[str]], str]: if not table_lines: logger.warning("No markdown table found in response") - return [], response_text.strip() + return [], [], response_text.strip() - def _split_row(line: str) -> list[str]: - """Split a markdown table row, preserving empty cells.""" - # Strip leading/trailing pipe and split - stripped = line.strip() - if stripped.startswith("|"): - stripped = stripped[1:] - if stripped.endswith("|"): - stripped = stripped[:-1] - return [c.strip() for c in stripped.split("|")] + headers = _split_row(table_lines[0]) - # Skip header row and separator line, collect data rows data_lines: list[list[str]] = [] - for tl in table_lines[1:]: # skip header + for tl in table_lines[1:]: cells = _split_row(tl) - # Skip separator rows (---|---|---) if cells and all(re.match(r"^[-:]+$", c) for c in cells if c): continue - if any(c for c in cells): # at least one non-empty cell + if any(c for c in cells): data_lines.append(cells) linguistic_summary = "\n".join(post_table_lines).strip() - return data_lines, linguistic_summary + return headers, data_lines, linguistic_summary def _rows_to_draft_outputs( + headers: list[str], rows: list[list[str]], source_lines: list[Any], ) -> tuple[list[DraftOutput], list[RankingDeclaration]]: """Convert parsed table rows into DraftOutput and RankingDeclaration objects. - The V25 table has columns (by position): + The V25 spec table has columns (by position): 0: Locale | 1: Source | 2: Option 1 | 3: Backtranslation | 4: Rationale | 5: Option 2 | 6: Backtranslation | 7: Rationale | 8: Option 3 | 9: Backtranslation | 10: Rationale - Rows are passed as lists of cell strings (not dicts) because the V25 - table has duplicate column names that would collide in a dict. + The LLM occasionally emits extra metadata columns (e.g. Copy Type, Char + Limit) between Source and Option 1, which shifts later columns. To stay + correct, we locate the "Option 1" header position and read the 9 + option/BT/rationale cells from that anchor — robust to any prefix shift. """ drafts: list[DraftOutput] = [] rankings: list[RankingDeclaration] = [] + opt1_idx = _find_option1_index(headers) if headers else None + if opt1_idx is None: + # Header missing or unrecognised — fall back to fixed position 2 + opt1_idx = 2 + logger.warning( + "Could not locate 'Option 1' column in header (%s); " + "falling back to fixed position %d", + headers, opt1_idx, + ) + else: + logger.info("Anchoring on 'Option 1' at column %d (headers: %s)", + opt1_idx, headers) + + def _cell(cells: list[str], idx: int) -> str: + return cells[idx] if 0 <= idx < len(cells) else "" + for i, cells in enumerate(rows): - # Expected 11 columns; minimum useful: 5 (locale, source, opt1, bt1, rat1) - if len(cells) < 5: - logger.warning("Row %d has too few columns (%d), skipping", i, len(cells)) + if len(cells) < opt1_idx + 3: + logger.warning( + "Row %d has too few columns (%d) to extract option 1 at index %d, skipping", + i, len(cells), opt1_idx, + ) continue - # Extract values by position - opt1_text = cells[2] if len(cells) > 2 else "" - bt1 = cells[3] if len(cells) > 3 else "" - rat1 = cells[4] if len(cells) > 4 else "" - opt2_text = cells[5] if len(cells) > 5 else "" - bt2 = cells[6] if len(cells) > 6 else "" - rat2 = cells[7] if len(cells) > 7 else "" - opt3_text = cells[8] if len(cells) > 8 else "" - bt3 = cells[9] if len(cells) > 9 else "" - rat3 = cells[10] if len(cells) > 10 else "" + opt1_text = _cell(cells, opt1_idx + 0) + bt1 = _cell(cells, opt1_idx + 1) + rat1 = _cell(cells, opt1_idx + 2) + opt2_text = _cell(cells, opt1_idx + 3) + bt2 = _cell(cells, opt1_idx + 4) + rat2 = _cell(cells, opt1_idx + 5) + opt3_text = _cell(cells, opt1_idx + 6) + bt3 = _cell(cells, opt1_idx + 7) + rat3 = _cell(cells, opt1_idx + 8) # Clean up
tags to newlines def _clean(text: str) -> str: @@ -309,7 +364,7 @@ class AgentSingle(BaseAgent): return "" def parse_response(self, response: str, context: PipelineContext) -> Any: - """Parse markdown table response.""" + """Parse markdown table response. Returns (headers, rows, summary).""" return _parse_markdown_table(response) async def run(self, context: PipelineContext) -> PipelineContext: @@ -384,10 +439,30 @@ class AgentSingle(BaseAgent): len(system_prompt), len(user_message), ) + # Scale max_tokens with source line count. Empirically each output + # row consumes ~250 output tokens (option text + BT + rationale x3 + # + table padding). 64k is the Sonnet 4.6 cap; 32k covers ~120 rows + # with comfortable headroom; 16k was the Round 1 default and was + # truncating large briefs (172 lines → 65 rows). + line_count = len(context.source_lines) + if line_count <= 30: + max_tokens = 8192 + elif line_count <= 80: + max_tokens = 16384 + elif line_count <= 150: + max_tokens = 32768 + else: + max_tokens = 64000 + + logger.info( + "max_tokens=%d for %d source lines", + max_tokens, line_count, + ) + response_text, usage = await llm.acreate_message( system_prompt=system_prompt, user_message=user_message, - max_tokens=16384, + max_tokens=max_tokens, temperature=0.7, ) @@ -404,17 +479,16 @@ class AgentSingle(BaseAgent): context.total_estimated_cost += usage.get("estimated_cost_usd", 0.0) # ── Parse response ─────────────────────────────────────────── - rows, linguistic_summary = _parse_markdown_table(response_text) + headers, rows, linguistic_summary = _parse_markdown_table(response_text) logger.info("Parsed %d output rows from markdown table", len(rows)) if not rows: logger.error("No rows parsed from response. Raw response:\n%s", response_text[:2000]) elif rows: - # Log first row column count for debugging logger.info("First row has %d columns: %s", len(rows[0]), [c[:30] for c in rows[0]]) - # Convert to structured outputs - drafts, rankings = _rows_to_draft_outputs(rows, context.source_lines) + # Convert to structured outputs (anchors on Option 1 header position) + drafts, rankings = _rows_to_draft_outputs(headers, rows, context.source_lines) context.draft_outputs = drafts context.ranking_declarations = rankings diff --git a/backend/app/pipeline/modules/excel_writer.py b/backend/app/pipeline/modules/excel_writer.py index 399945c..0756368 100644 --- a/backend/app/pipeline/modules/excel_writer.py +++ b/backend/app/pipeline/modules/excel_writer.py @@ -133,56 +133,34 @@ def generate_output_xlsx( # ---- Tab 2: Linguistic Summary ---- ws2 = wb.create_sheet("Linguistic Summary") summary_data = summary or {} + ws2.column_dimensions["A"].width = 100 - if linguistic_summary: - # Linguistic summary from single agent - ws2.column_dimensions["A"].width = 100 + title_cell = ws2.cell(row=1, column=1, value="Linguistic Summary") + title_cell.font = Font(bold=True, size=14) - title_cell = ws2.cell(row=1, column=1, value="Linguistic Summary") - title_cell.font = Font(bold=True, size=14) - - summary_cell = ws2.cell(row=3, column=1, value=linguistic_summary) - summary_cell.alignment = Alignment(wrap_text=True, vertical="top") - - # Set row height based on content length - line_count = linguistic_summary.count("\n") + 1 - ws2.row_dimensions[3].height = max(15, min(600, line_count * 15)) - - # Metadata footer - footer_row = 5 + line_count - footer_text = ( - f"Job: {summary_data.get('job_id', '')} | " - f"Locale: {summary_data.get('locale_code', '')} | " - f"Generated: {summary_data.get('generated_at', '')}" + body = linguistic_summary.strip() if linguistic_summary else "" + if not body: + body = ( + "No linguistic summary was generated for this job. " + "This can happen if the agent's output was truncated or " + "if no commentary was produced after the translation table." ) - footer_cell = ws2.cell(row=footer_row, column=1, value=footer_text) - footer_cell.font = Font(color="888888", size=9) - else: - # Fallback: metadata key/value summary - ws2.column_dimensions["A"].width = 25 - ws2.column_dimensions["B"].width = 40 - metadata_rows = [ - ("Job ID", summary_data.get("job_id", "")), - ("Campaign", summary_data.get("campaign_name", "")), - ("Locale", summary_data.get("locale_code", "")), - ("Channel", summary_data.get("channel", "")), - ("Programme", summary_data.get("programme", "")), - ("Total Source Lines", summary_data.get("total_source_lines", 0)), - ("Total Output Rows", summary_data.get("total_output_rows", 0)), - ("High Confidence", summary_data.get("high_confidence", 0)), - ("Moderate Confidence", summary_data.get("moderate_confidence", 0)), - ("Low Confidence", summary_data.get("low_confidence", 0)), - ("Total Tokens Used", summary_data.get("total_tokens", 0)), - ("Estimated Cost (USD)", summary_data.get("estimated_cost", 0.0)), - ("Agent Version", summary_data.get("agent_version", "")), - ("Generated At", summary_data.get("generated_at", "")), - ] + summary_cell = ws2.cell(row=3, column=1, value=body) + summary_cell.alignment = Alignment(wrap_text=True, vertical="top") - for row_idx, (label, value) in enumerate(metadata_rows, start=1): - label_cell = ws2.cell(row=row_idx, column=1, value=label) - label_cell.font = Font(bold=True) - ws2.cell(row=row_idx, column=2, value=str(value)) + line_count = body.count("\n") + 1 + ws2.row_dimensions[3].height = max(15, min(600, line_count * 15)) + + # Metadata footer (job id / locale / generated at) + footer_row = 5 + line_count + footer_text = ( + f"Job: {summary_data.get('job_id', '')} | " + f"Locale: {summary_data.get('locale_code', '')} | " + f"Generated: {summary_data.get('generated_at', '')}" + ) + footer_cell = ws2.cell(row=footer_row, column=1, value=footer_text) + footer_cell.font = Font(color="888888", size=9) # Save Path(output_path).parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/services/job_service.py b/backend/app/services/job_service.py index e67963e..ee2ddb0 100644 --- a/backend/app/services/job_service.py +++ b/backend/app/services/job_service.py @@ -31,8 +31,13 @@ class JobService: else: job_type = JobType.main - # Default tm_channels to [channel] if not provided - tm_channels = data.tm_channels if data.tm_channels else [data.channel] + # Default tm_channels to [channel] only if user didn't send the field at all. + # An explicit empty list means "do not load any TMs" — respected as-is. + tm_channels = ( + data.tm_channels + if data.tm_channels is not None + else [data.channel] + ) job = Job( client_id=data.client_id, diff --git a/backend/app/services/output_service.py b/backend/app/services/output_service.py index c293403..6023f23 100644 --- a/backend/app/services/output_service.py +++ b/backend/app/services/output_service.py @@ -1,18 +1,26 @@ +import logging +import shutil +import tempfile +from pathlib import Path from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.models.feedback import Feedback from app.models.job import LocaleInstance from app.models.output import OutputRow from app.models.source import SourceLine +from app.models.user import User from app.schemas.output import ( OutputPreviewResponse, OutputRowResponse, SourceLinePreview, ) +logger = logging.getLogger(__name__) + class OutputService: """Service for assembling output preview data and triggering exports.""" @@ -86,7 +94,13 @@ class OutputService: job_id: UUID, locale_code: str, ) -> str | None: - """Trigger export generation for a locale and return the file path.""" + """Return path to the export xlsx, with any reviewer feedback applied. + + If reviewers have left comments on output rows, this method copies + the original xlsx to a temp path, appends "Reviewer (Name): comment" + to the affected rationale cells, and returns the temp path. If no + feedback exists, returns the original path unchanged. + """ result = await db.execute( select(LocaleInstance) .where( @@ -95,11 +109,81 @@ class OutputService: ) ) instance = result.scalar_one_or_none() - if instance is None: + if instance is None or not instance.output_file_path: return None - if instance.output_file_path: - return instance.output_file_path + original_path = instance.output_file_path + if not Path(original_path).exists(): + return None - # Export would be triggered here; for now return None indicating no export yet - return None + # Pull all feedback for this instance's output rows + feedback_query = ( + select(Feedback, OutputRow.row_order, User.name) + .join(OutputRow, Feedback.output_id == OutputRow.id) + .join(User, Feedback.user_id == User.id) + .where(OutputRow.instance_id == instance.id) + .order_by(OutputRow.row_order, Feedback.created_at) + ) + feedback_rows = (await db.execute(feedback_query)).all() + + if not feedback_rows: + return original_path + + # Group by (row_order, option_column) for placement in the xlsx + from collections import defaultdict + grouped: dict[tuple[int, int], list[str]] = defaultdict(list) + for fb, row_order, user_name in feedback_rows: + if not fb.comment: + continue + label = f"Reviewer ({user_name}): {fb.comment}" + grouped[(row_order, fb.option_column)].append(label) + + if not grouped: + return original_path + + return self._render_export_with_feedback(original_path, grouped) + + def _render_export_with_feedback( + self, + original_path: str, + feedback_by_row: dict[tuple[int, int], list[str]], + ) -> str: + """Copy the xlsx to a temp path and append reviewer comments to + the rationale cells. Returns the temp path.""" + from openpyxl import load_workbook + from openpyxl.styles import Alignment + + # Tab 1 column indices for rationale of each option (matches excel_writer.OUTPUT_COLUMNS) + # 1: EN_GB, 2: Copy Type, 3: Option 1, 4: BT 1, 5: Rationale 1, + # 6: Option 2, 7: BT 2, 8: Rationale 2, + # 9: Option 3, 10: BT 3, 11: Rationale 3 + RATIONALE_COL_BY_OPTION = {1: 5, 2: 8, 3: 11} + + tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) + tmp.close() + shutil.copy2(original_path, tmp.name) + + try: + wb = load_workbook(tmp.name) + ws = wb["Transcreation Output"] + except Exception as exc: + logger.warning( + "Could not open xlsx for feedback injection (%s); " + "returning original", exc, + ) + return original_path + + for (row_order, option_col), comments in feedback_by_row.items(): + col_idx = RATIONALE_COL_BY_OPTION.get(option_col) + if col_idx is None: + continue + xlsx_row = row_order + 1 # +1 for header + cell = ws.cell(row=xlsx_row, column=col_idx) + existing = cell.value or "" + suffix = "\n\n" + "\n".join(comments) + cell.value = (existing + suffix) if existing else suffix.lstrip() + cell.alignment = Alignment(vertical="top", wrap_text=True) + + wb.save(tmp.name) + wb.close() + return tmp.name diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 90ef479..150b8f6 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -171,16 +171,19 @@ class ReportService: query = query.where(Job.created_at <= date_to) result = await db.execute(query) - return [ - { + out: list[dict[str, Any]] = [] + for row in result.all(): + count = row.count or 0 + total_cost = float(row.total_cost or 0.0) + out.append({ "locale": row.locale_code, - "count": row.count, + "count": count, "total_tokens": row.total_tokens or 0, - "total_cost": float(row.total_cost or 0.0), + "total_cost": total_cost, + "avg_cost_per_brief": round(total_cost / count, 4) if count else 0.0, "avg_duration_minutes": round(float(row.avg_duration_seconds or 0) / 60, 1), - } - for row in result.all() - ] + }) + return out async def get_jobs_over_time( self, diff --git a/backend/app/tasks/job_tasks.py b/backend/app/tasks/job_tasks.py index 7c212df..02082be 100644 --- a/backend/app/tasks/job_tasks.py +++ b/backend/app/tasks/job_tasks.py @@ -50,12 +50,19 @@ def _resolve_file_manifest( def _check(path: str) -> str | None: return path if os.path.exists(path) else None - # Resolve TM file + # Resolve TM file. Try the legacy registry first; if the channel isn't + # listed, fall back to the generic pattern so newly-registered channels + # (e.g. PrimeCBM) work without code changes. tm_files: list[str] = [] channel_lower = channel.lower() if channel else "" tm_pattern = TM_CHANNEL_REGISTRY.get(channel_lower) if tm_pattern: tm_filename = tm_pattern.replace("{lc}", lc_lower) + elif channel: + tm_filename = f"flat_{channel}_{lc_lower}.json" + else: + tm_filename = "" + if tm_filename: tm_path = f"{storage}/amazon/tm/{locale_code}/{tm_filename}" if os.path.exists(tm_path): tm_files.append(tm_path) diff --git a/frontend/src/app/admin/reports/page.tsx b/frontend/src/app/admin/reports/page.tsx index 55bd4ba..a41396a 100644 --- a/frontend/src/app/admin/reports/page.tsx +++ b/frontend/src/app/admin/reports/page.tsx @@ -319,6 +319,52 @@ export default function ReportsPage() { + + {/* Cost by Locale */} + + + Cost by Locale + + + {localeStats.length > 0 ? ( +
+ + + + + + + + + + + + {localeStats.map((l) => ( + + + + + + + + ))} + + + + + + + + +
LocaleBriefsTotal Cost (USD)Avg Cost / BriefTotal Tokens
{l.locale}{l.count}${l.total_cost.toFixed(2)}${(l.avg_cost_per_brief ?? 0).toFixed(4)}{l.total_tokens.toLocaleString()}
Total{localeStats.reduce((s, l) => s + l.count, 0)}${localeStats.reduce((s, l) => s + l.total_cost, 0).toFixed(2)}{localeStats.reduce((s, l) => s + l.total_tokens, 0).toLocaleString()}
+
+ ) : ( +

+ No locale data for this period. +

+ )} +
+
)} diff --git a/frontend/src/app/jobs/[jobId]/page.tsx b/frontend/src/app/jobs/[jobId]/page.tsx index 983b14b..c035920 100644 --- a/frontend/src/app/jobs/[jobId]/page.tsx +++ b/frontend/src/app/jobs/[jobId]/page.tsx @@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { LocaleInstanceCard } from "@/components/jobs/MonitoringHUD/LocaleInstanceCard"; import { useJob } from "@/hooks/useJobs"; -import { cancelJob, deleteJob } from "@/lib/api"; +import { cancelJob, deleteJob, listSupplementary } from "@/lib/api"; import { useAuth } from "@/hooks/useAuth"; import { Clock, @@ -41,6 +41,14 @@ export default function JobMonitoringPage() { const { job, loading, error, refetch } = useJob(jobId); const { user } = useAuth(); const [confirmDelete, setConfirmDelete] = useState(false); + const [suppFiles, setSuppFiles] = useState([]); + + useEffect(() => { + if (!jobId) return; + listSupplementary(jobId) + .then((res) => setSuppFiles(res.files)) + .catch(() => setSuppFiles([])); + }, [jobId]); const isAdmin = user?.role === "ADMIN"; @@ -206,6 +214,64 @@ export default function JobMonitoringPage() { + {/* Job Inputs — what was sent to the agent */} + + + Inputs sent to the agent + + +
+

AI Model

+

+ {job.llm_model === "claude-opus-4-6" + ? "Claude Opus 4.6" + : job.llm_model === "claude-sonnet-4-6" + ? "Claude Sonnet 4.6" + : job.llm_model || "Default"} +

+
+
+

TM Files Loaded

+ {job.tm_channels && job.tm_channels.length > 0 ? ( +
+ {job.tm_channels.map((ch) => ( + + {ch} + + ))} +
+ ) : ( +

None — pipeline ran without TM files

+ )} +
+
+

Supplementary Files

+ {suppFiles.length > 0 ? ( +
    + {suppFiles.map((f) => ( +
  • + + {f} +
  • + ))} +
+ ) : ( +

None

+ )} +
+
+

Context Override

+ {job.context_override ? ( +
+{job.context_override}
+                
+ ) : ( +

None

+ )} +
+
+
+ {/* Locale instances */}

diff --git a/frontend/src/components/jobs/JobWizard/StepConfigure.tsx b/frontend/src/components/jobs/JobWizard/StepConfigure.tsx index 6b27d3e..dc49a15 100644 --- a/frontend/src/components/jobs/JobWizard/StepConfigure.tsx +++ b/frontend/src/components/jobs/JobWizard/StepConfigure.tsx @@ -16,16 +16,16 @@ import { } from "@/components/ui/select"; import type { JobFormData } from "@/app/jobs/new/page"; import type { Client } from "@/lib/types"; -import { getClients } from "@/lib/api"; +import { getClients, getTMChannels } from "@/lib/api"; const ALL_LOCALES = [ "de-DE", "fr-FR", "it-IT", "es-ES", "nl-NL", "sv-SE", "pl-PL", "pt-PT", "de-AT", "fr-BE", "nl-BE", "ca-ES", ]; -const CHANNELS = ["MASS", "VALUE", "ONSITE", "OUTBOUND", "BDA", "UEFA"]; - -const TM_CHANNELS = [ +// Fallback list used until the registry-discovered list arrives. Keeps +// the wizard usable on first paint and in case the API call fails. +const DEFAULT_CHANNELS = [ "MASS", "VALUE", "ONSITE", "OUTBOUND", "UEFA", "BDA", "DoubleDonut", "EUSelection", "PrimeDualBenefit", "PrimeGourmetGuard", "PrimeMidfunnel", "PrimeSpeed", "TheKiss", @@ -55,6 +55,7 @@ interface StepConfigureProps { export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) { const [clients, setClients] = useState([]); + const [discoveredChannels, setDiscoveredChannels] = useState([]); useEffect(() => { getClients() @@ -75,6 +76,23 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) { .catch(() => {}); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Fetch the registry-discovered channel list whenever the client changes. + // New TM uploads (e.g. flat_PrimeCBM_de-de.json) appear here automatically. + useEffect(() => { + if (!data.client_id) return; + getTMChannels(data.client_id) + .then((channels) => { + const names = channels.map((c) => c.name); + setDiscoveredChannels(names); + }) + .catch(() => setDiscoveredChannels([])); + }, [data.client_id]); + + // Use registry channels when available; fall back to the static default + // list (covers the very-first-load and offline cases). + const channelOptions = + discoveredChannels.length > 0 ? discoveredChannels : DEFAULT_CHANNELS; + const toggleLocale = (locale: string) => { if (data.locales.includes(locale)) { onChange({ locales: data.locales.filter((l) => l !== locale) }); @@ -93,7 +111,7 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) { onChange({ tm_channels: [...data.tm_channels, ch] }); } }; - const selectAllTM = () => onChange({ tm_channels: [...TM_CHANNELS] }); + const selectAllTM = () => onChange({ tm_channels: [...channelOptions] }); const clearAllTM = () => onChange({ tm_channels: [] }); const isValid = @@ -182,18 +200,13 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {