Fix markdown table parser losing backtranslations/rationales, add model selection, update help page
The V25 table has duplicate column names (Backtranslation x3, Rationale x3). The dict-based parser collapsed these — only the last value survived (Option 3's "N/A"), causing all BT/rationale fields to be "N/A" in the output Excel. Fixed by switching to positional list-based parsing instead of dicts. Also adds per-job model selection (Sonnet 4.6 / Opus 4.6) through the full stack: DB column, API schema, job wizard UI dropdown, pipeline contracts, and LLM client with model-aware cost tracking. Includes Alembic migration. Updated help page and README to reflect single-agent pipeline, multi-TM selection, flat locale grid, model selector, and linguistic summary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d56311b862
commit
d5fa4e49f7
14 changed files with 236 additions and 151 deletions
153
README.md
153
README.md
|
|
@ -45,13 +45,9 @@ An AI-powered transcreation platform that adapts Amazon marketing copy across 12
|
|||
│ ┌────────────────────▼───────────────────────┐ │
|
||||
│ │ Pipeline Orchestrator │ │
|
||||
│ │ │ │
|
||||
│ │ VALIDATE ► TM_RETRIEVE ► RANK │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ ▼ ▼ ▼ │ │
|
||||
│ │ TRANSCREATE ◄── COMPLY ──► FORMAT │ │
|
||||
│ │ │ (retry x3) │ │ │
|
||||
│ │ ▼ ▼ │ │
|
||||
│ │ DONE │ │
|
||||
│ │ VALIDATE ► SINGLE_AGENT ► FORMAT ► DONE │ │
|
||||
│ │ │ │
|
||||
│ │ (Single LLM call with full V25 prompt) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└──────────┬──────────────────────────┬─────────────┘
|
||||
│ │
|
||||
|
|
@ -67,8 +63,8 @@ An AI-powered transcreation platform that adapts Amazon marketing copy across 12
|
|||
│ Claude API │ │ File Storage │
|
||||
│ (Anthropic) │ │ │
|
||||
│ │ │ /storage/amazon/ │
|
||||
│ Agents 2 & 4 │ │ tm/ (JSONL) │
|
||||
│ make LLM calls │ │ ref/ (JSON) │
|
||||
│ Single agent │ │ tm/ (JSONL) │
|
||||
│ (1 LLM call) │ │ ref/ (JSON) │
|
||||
└─────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -99,12 +95,11 @@ An AI-powered transcreation platform that adapts Amazon marketing copy across 12
|
|||
│ │ │ │
|
||||
│ 4. Monitor Progress │ ┌───────▼────────┐ │
|
||||
│ (polls every 3 sec) │ │ Agent Pipeline │ │
|
||||
│ ◄─── 25% Matching TM ─────│ │ │──── LLM ────►│
|
||||
│ ◄─── 50% Translating ─────│ │ 6 stages per │◄── matches ──│
|
||||
│ ◄─── 65% Batch 2/4 ───────│ │ locale │──── LLM ────►│
|
||||
│ ◄─── 80% Batch 4/4 ───────│ │ │◄── drafts ───│
|
||||
│ ◄─── 90% Formatting ──────│ └───────┬─────────┘ │
|
||||
│ ◄── 100% Complete ────────│ │ │
|
||||
│ ◄─── 10% Loading Files ───│ │ │ │
|
||||
│ ◄─── 20% Transcreating ───│ │ Single agent │──── LLM ────►│
|
||||
│ ◄─── 90% Formatting ──────│ │ per locale │◄── table ────│
|
||||
│ ◄── 100% Complete ────────│ │ │ │
|
||||
│ │ └───────┬─────────┘ │
|
||||
│ │ ┌───────▼────────┐ │
|
||||
│ 5. Review Output │ │ Output saved │ │
|
||||
│ (per-locale, per-line │ │ to DB + xlsx │ │
|
||||
|
|
@ -120,66 +115,56 @@ An AI-powered transcreation platform that adapts Amazon marketing copy across 12
|
|||
|
||||
### What Happens When You Launch a Job
|
||||
|
||||
1. **Job created** with campaign name, programme (Retail/Prime/Brand), channel (Value/Mass/Onsite/Outbound), and target locales
|
||||
1. **Job created** with campaign name, programme (Retail/Prime/Brand), channel, multiple TM files, and target locales (all 12 selectable in a single flat list)
|
||||
2. **Source file uploaded** - an xlsx with English (en_GB) marketing copy, character limits, copy types, and creative guidance
|
||||
3. **Launch** dispatches one Celery task per locale - up to 4 run in parallel
|
||||
4. Each locale runs through the **6-agent pipeline** (see below)
|
||||
4. Each locale runs through the **single-agent pipeline** — one LLM call with the full V25 prompt (see below)
|
||||
5. Real-time **progress updates** are stored in the database and polled by the frontend every 3 seconds
|
||||
6. On completion, output is viewable in the **review interface** with confidence badges, backtranslations, and rationale
|
||||
7. **Export** downloads a formatted xlsx file
|
||||
7. **Export** downloads a formatted xlsx (Tab 1: output table, Tab 2: linguistic summary)
|
||||
|
||||
---
|
||||
|
||||
## The Agent Pipeline
|
||||
|
||||
Each locale goes through 6 sequential agents. The pipeline includes a compliance retry loop (max 3 attempts).
|
||||
### Single-Agent Pipeline (Default)
|
||||
|
||||
The platform uses a **single consolidated LLM call** with the complete V25 Agent Instructions JSON as the system prompt. This replaces the earlier 6-agent sequential pipeline and produces better results by preserving inter-step context (TM reasoning, ranking rationale, cultural nuance) within a single prompt.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ PER-LOCALE PIPELINE │
|
||||
│ │
|
||||
│ ┌──────────────┐ Deterministic. Parses xlsx, loads glossary, │
|
||||
│ │ AGENT 1 │ blacklist, TOV, locale considerations, and │
|
||||
│ │ Validator │ date/percent format files. Builds PipelineContext. │
|
||||
│ │ [no LLM] │ ~0.1 seconds │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ VALIDATE │ blacklist, TOV, locale considerations, and │
|
||||
│ │ [no LLM] │ date/percent format files. Builds PipelineContext. │
|
||||
│ └──────┬───────┘ ~0.1 seconds │
|
||||
│ │ 10% │
|
||||
│ ┌──────▼───────┐ Loads TM file (~128 entries for de-DE Value). │
|
||||
│ │ AGENT 2 │ Sends ALL source lines + ALL TM entries to │
|
||||
│ │ TM Retrieval│ Claude in one call. LLM finds semantic matches │
|
||||
│ │ [LLM call] │ (exact / high / medium similarity). │
|
||||
│ └──────┬───────┘ ~45 seconds, ~$0.13 │
|
||||
│ │ 25% │
|
||||
│ ┌──────▼───────┐ Deterministic. Scores each match by channel │
|
||||
│ │ AGENT 3 │ fit, sub-channel fit, and recency (year). │
|
||||
│ │ Ranker │ Assigns confidence: HIGH (1 opt), MODERATE │
|
||||
│ │ [no LLM] │ (2 opts), LOW (3 opts). │
|
||||
│ └──────┬───────┘ ~0.01 seconds │
|
||||
│ │ 40% │
|
||||
│ ┌──────▼───────┐ Core creative agent. Processes source lines in │
|
||||
│ │ AGENT 4 │ batches of 15. System prompt includes voice │
|
||||
│ │ Transcreator│ profile, glossary, blacklist, TOV guidelines. │
|
||||
│ │ [LLM call] │ Generates options with backtranslations and │
|
||||
│ │ x4 batches │ rationale. Cites TM entries where applicable. │
|
||||
│ └──────┬───────┘ ~4 min (4 batches), ~$0.36 │
|
||||
│ │ 50-80% │
|
||||
│ ┌──────▼───────┐ Deterministic. 3 checks: │
|
||||
│ │ AGENT 5 │ 1. Character count vs char_limit │
|
||||
│ │ Compliance │ 2. Blacklist term scanning │
|
||||
│ │ [no LLM] │ 3. Domain check (Amazon.co.uk in non-en_GB) │
|
||||
│ └──────┬───────┘ If errors: retry from Agent 4 (max 3x) │
|
||||
│ │ 82% ~0.01 seconds │
|
||||
│ ┌──────▼───────┐ │
|
||||
│ │ AGENT 6 │ Deterministic. Generates output xlsx │
|
||||
│ │ Formatter │ (Tab 1: output table, Tab 2: summary). │
|
||||
│ │ [no LLM] │ Saves output rows to database. │
|
||||
│ ┌──────▼───────┐ Single LLM call using V25 Agent Instructions. │
|
||||
│ │ SINGLE │ System prompt: full V25 JSON (899 lines). │
|
||||
│ │ AGENT │ User message: job params, ALL source lines, │
|
||||
│ │ [1 LLM call]│ ALL TM entries (multiple channels), ALL reference │
|
||||
│ │ │ files (glossary, blacklist, TOV, locale rules). │
|
||||
│ │ │ │
|
||||
│ │ │ The agent handles TM matching, ranking, │
|
||||
│ │ │ transcreation, and compliance in one pass. │
|
||||
│ │ │ Outputs a markdown table + linguistic summary. │
|
||||
│ └──────┬───────┘ ~2-4 min, ~$0.30-0.50 │
|
||||
│ │ 20-90% │
|
||||
│ ┌──────▼───────┐ Deterministic. Generates output xlsx: │
|
||||
│ │ FORMAT │ Tab 1: 11-column output table │
|
||||
│ │ [no LLM] │ Tab 2: Linguistic summary from the agent │
|
||||
│ └──────┬───────┘ ~0.1 seconds │
|
||||
│ │ 100% │
|
||||
│ ▼ │
|
||||
│ DONE (~5.5 min total, ~$0.49 per locale) │
|
||||
│ DONE (~2-4 min total per locale) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Legacy 6-Agent Pipeline (Feature Flag)
|
||||
|
||||
The original 6-agent sequential pipeline is preserved behind a feature flag (`USE_SINGLE_AGENT=false`). It runs: VALIDATE → TM_RETRIEVE → RANK → TRANSCREATE → COMPLY (retry x3) → FORMAT → DONE. This path makes 2+ LLM calls (TM retrieval + transcreation in batches) and takes longer (~5.5 min per locale).
|
||||
|
||||
### Confidence Tiers and Option Counts
|
||||
|
||||
```
|
||||
|
|
@ -212,7 +197,7 @@ The pipeline uses 9 pure-Python modules (no LLM) for specific tasks:
|
|||
| `date_format_validator` | Validate date/percent formats per locale |
|
||||
| `domain_substitutor` | Amazon.co.uk to locale-specific domain mapping |
|
||||
| `line_break_normaliser` | Handle `\n` for TM queries vs Excel output |
|
||||
| `excel_writer` | Generate formatted xlsx with output + summary tabs |
|
||||
| `excel_writer` | Generate formatted xlsx (Tab 1: output table, Tab 2: linguistic summary) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -315,7 +300,8 @@ All configuration is via environment variables in `.env`:
|
|||
| `JWT_ALGORITHM` | `HS256` | JWT signing algorithm |
|
||||
| `JWT_EXPIRY_HOURS` | `8` | Access token expiry in hours |
|
||||
| `STORAGE_ROOT` | `/storage` | Root path for file storage |
|
||||
| `LLM_MODEL` | `claude-sonnet-4-6` | Claude model to use for agents |
|
||||
| `LLM_MODEL` | `claude-sonnet-4-6` | Default Claude model (overridden per-job via UI: `claude-sonnet-4-6` or `claude-opus-4-6`) |
|
||||
| `USE_SINGLE_AGENT` | `true` | Use single-agent pipeline (`true`) or legacy 6-agent (`false`) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -325,10 +311,12 @@ All configuration is via environment variables in `.env`:
|
|||
storage/amazon/
|
||||
├── tm/ # Translation Memory files (JSONL)
|
||||
│ ├── de-DE/
|
||||
│ │ ├── flat_value_de-de.json # Value channel TM (~128 entries)
|
||||
│ │ ├── flat_MASS_de-de.json # Mass channel TM
|
||||
│ │ ├── flat_value_de-de.json # Value channel TM
|
||||
│ │ ├── flat_Onsite_de-de.json # Onsite channel TM
|
||||
│ │ └── flat_Outbound_de-de.json # Outbound channel TM
|
||||
│ │ ├── flat_Outbound_de-de.json # Outbound channel TM
|
||||
│ │ ├── flat_UEFA_de-de.json # UEFA channel TM
|
||||
│ │ └── ... # + BDA, DoubleDonut, EUSelection, etc.
|
||||
│ ├── fr-FR/
|
||||
│ │ └── ...
|
||||
│ └── ... (12 locale directories)
|
||||
|
|
@ -387,12 +375,23 @@ Each line is a JSON object. Two formats are supported:
|
|||
|
||||
### Channels & TM Files
|
||||
|
||||
Jobs can select **multiple TM channels** to load into the agent's context. The campaign channel is auto-selected, and users can add additional TM files for cross-channel reference (e.g. MASS as a fallback alongside the primary channel).
|
||||
|
||||
| Channel | TM File Pattern |
|
||||
|---------|----------------|
|
||||
| Value | `flat_value_{lc}.json` |
|
||||
| Mass | `flat_MASS_{lc}.json` |
|
||||
| Value | `flat_value_{lc}.json` |
|
||||
| Onsite | `flat_Onsite_{lc}.json` |
|
||||
| Outbound | `flat_Outbound_{lc}.json` |
|
||||
| UEFA | `flat_UEFA_{lc}.json` |
|
||||
| BDA | `flat_BDA_{lc}.json` |
|
||||
| DoubleDonut | `flat_DoubleDonut_{lc}.json` |
|
||||
| EUSelection | `flat_EUSelection_{lc}.json` |
|
||||
| PrimeDualBenefit | `flat_PrimeDualBenefit_{lc}.json` |
|
||||
| PrimeGourmetGuard | `flat_PrimeGourmetGuard_{lc}.json` |
|
||||
| PrimeMidfunnel | `flat_PrimeMidfunnel_{lc}.json` |
|
||||
| PrimeSpeed | `flat_PrimeSpeed_{lc}.json` |
|
||||
| TheKiss | `flat_TheKiss_{lc}.json` |
|
||||
|
||||
### Programmes & Voice Profiles
|
||||
|
||||
|
|
@ -483,9 +482,10 @@ Each line is a JSON object. Two formats are supported:
|
|||
│ campaign_name│ │ source_lines │
|
||||
│ programme │ │──────────────────│
|
||||
│ channel │ │ id (PK) │
|
||||
│ status │◄────│ job_id (FK) │
|
||||
│ job_type │ │ en_gb │
|
||||
└──────┬───────┘ │ copy_type │
|
||||
│ tm_channels │ │ job_id (FK) │
|
||||
│ status │◄────│ en_gb │
|
||||
│ job_type │ │ copy_type │
|
||||
└──────┬───────┘ │ char_limit │
|
||||
│ │ char_limit │
|
||||
│ │ is_display_format│
|
||||
┌──────▼───────────┐ └──────────────────┘
|
||||
|
|
@ -537,8 +537,9 @@ Each line is a JSON object. Two formats are supported:
|
|||
- **Client** - Select the client (e.g. Amazon)
|
||||
- **Campaign Name** - Name of the campaign (e.g. "DDA 26 BFW")
|
||||
- **Programme** - Retail, Prime, or Brand (determines voice profile)
|
||||
- **Channel** - Value, Mass, Onsite, or Outbound (determines TM file)
|
||||
- **Locales** - Select one or more target locales
|
||||
- **Channel** - Campaign channel (e.g. Value, Mass, Onsite, Outbound)
|
||||
- **TM Files** - Select one or more TM channels to load (campaign channel auto-selected; add MASS as fallback or other channels for cross-reference)
|
||||
- **Locales** - All 12 locales in a single flat grid (main and derived locales are auto-classified — no separate "Job Type" selection needed)
|
||||
3. Upload the **source xlsx** file with columns:
|
||||
- `EN_GB` (required) - English source copy
|
||||
- `Copy Type` - Type of copy (headline, body, CTA, script, etc.)
|
||||
|
|
@ -552,7 +553,7 @@ Each line is a JSON object. Two formats are supported:
|
|||
|
||||
Once launched, the job monitoring page shows real-time updates:
|
||||
- Per-locale progress bars (0-100%)
|
||||
- Current stage: Loading Files > Matching TM > Ranking > Translating (batch X/Y) > Reviewing > Formatting > Complete
|
||||
- Current stage: Loading Files > Transcreating > Formatting Output > Complete
|
||||
- Token usage and elapsed time
|
||||
- Error details if any locale fails
|
||||
|
||||
|
|
@ -568,7 +569,7 @@ Click **Preview** on a completed locale to open the review interface:
|
|||
- Every option includes a **backtranslation** and **character count**
|
||||
- Expandable **rationale** explains the translation choices and TM citations
|
||||
- Feedback buttons: **Approve**, **Needs Revision**, or add a **Comment**
|
||||
- **Export** button downloads the formatted xlsx
|
||||
- **Export** button downloads the formatted xlsx (Tab 1: output table, Tab 2: linguistic summary explaining the agent's approach and cultural choices)
|
||||
|
||||
### Admin Features
|
||||
|
||||
|
|
@ -600,9 +601,15 @@ amazon-transcreation/
|
|||
│ │ ├── schemas/ # Pydantic request/response models
|
||||
│ │ ├── services/ # Business logic layer
|
||||
│ │ ├── pipeline/
|
||||
│ │ │ ├── orchestrator.py # State machine (6 stages + retry)
|
||||
│ │ │ ├── orchestrator.py # State machine (single-agent or legacy 6-agent)
|
||||
│ │ │ ├── contracts.py # Inter-agent Pydantic models
|
||||
│ │ │ ├── agents/ # 6 pipeline agents
|
||||
│ │ │ ├── agents/
|
||||
│ │ │ │ ├── agent_single.py # Consolidated single-agent (V25 prompt)
|
||||
│ │ │ │ ├── agent_1_validator.py # Deterministic file/input validation
|
||||
│ │ │ │ ├── agent_6_formatter.py # Excel output generation
|
||||
│ │ │ │ ├── agent_2-5_*.py # Legacy agents (behind feature flag)
|
||||
│ │ │ │ └── prompts/
|
||||
│ │ │ │ └── v25_instructions.json # V25 Agent Instructions (system prompt)
|
||||
│ │ │ └── modules/ # 9 deterministic modules
|
||||
│ │ ├── tasks/ # Celery task definitions
|
||||
│ │ ├── llm/ # Anthropic SDK wrapper + retry
|
||||
|
|
@ -635,8 +642,8 @@ docker compose exec backend python -m pytest tests/ -v
|
|||
|
||||
1. Create TM files in `storage/amazon/tm/{locale_code}/`
|
||||
2. Create reference files in the appropriate `storage/amazon/ref/` subdirectories
|
||||
3. Add the locale to the frontend locale selector
|
||||
4. Add the locale display name to `LOCALE_NAMES` in `agent_4_transcreator.py`
|
||||
3. Add the locale code to `ALL_LOCALES` in `frontend/src/components/jobs/JobWizard/StepConfigure.tsx`
|
||||
4. If it's a derived locale, add it to `DERIVED_LOCALE_CODES` in `backend/app/services/job_service.py`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -668,11 +675,9 @@ docker compose exec backend python -m pytest tests/ -v
|
|||
|
||||
### Cost Estimation
|
||||
|
||||
For a typical 53-line source brief:
|
||||
For a typical 53-line source brief (single-agent pipeline):
|
||||
|
||||
| | Per Locale | 12 Locales |
|
||||
|---|-----------|------------|
|
||||
| Agent 2 (TM Retrieval) | ~$0.13 | ~$1.56 |
|
||||
| Agent 4 (Transcreation) | ~$0.36 | ~$4.32 |
|
||||
| **Total** | **~$0.49** | **~$5.88** |
|
||||
| Processing time | ~5.5 min | ~5.5 min (parallel) |
|
||||
| Single Agent (V25) | ~$0.30-0.50 | ~$3.60-6.00 |
|
||||
| Processing time | ~2-4 min | ~2-4 min (parallel) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
"""Add llm_model to jobs
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-04-14 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("jobs", sa.Column("llm_model", sa.String(100), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("jobs", "llm_model")
|
||||
|
|
@ -10,9 +10,15 @@ from app.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost per token (approximate, varies by model)
|
||||
COST_PER_INPUT_TOKEN = 3.0 / 1_000_000 # $3 per 1M input tokens
|
||||
COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000 # $15 per 1M output tokens
|
||||
# Cost per token by model (approximate)
|
||||
MODEL_COSTS: dict[str, tuple[float, float]] = {
|
||||
# (input_cost_per_token, output_cost_per_token)
|
||||
"claude-sonnet-4-6": (3.0 / 1_000_000, 15.0 / 1_000_000),
|
||||
"claude-opus-4-6": (15.0 / 1_000_000, 75.0 / 1_000_000),
|
||||
}
|
||||
# Default fallback (Sonnet pricing)
|
||||
COST_PER_INPUT_TOKEN = 3.0 / 1_000_000
|
||||
COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000
|
||||
|
||||
|
||||
class LLMClient:
|
||||
|
|
@ -81,9 +87,12 @@ class LLMClient:
|
|||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
total_tokens = input_tokens + output_tokens
|
||||
input_rate, output_rate = MODEL_COSTS.get(
|
||||
self.model, (COST_PER_INPUT_TOKEN, COST_PER_OUTPUT_TOKEN)
|
||||
)
|
||||
estimated_cost = (
|
||||
input_tokens * COST_PER_INPUT_TOKEN
|
||||
+ output_tokens * COST_PER_OUTPUT_TOKEN
|
||||
input_tokens * input_rate
|
||||
+ output_tokens * output_rate
|
||||
)
|
||||
|
||||
usage = {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ class Job(Base, TimestampMixin):
|
|||
channel: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
sub_channel: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
tm_channels: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
llm_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
context_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
job_type: Mapped[JobType] = mapped_column(
|
||||
Enum(JobType, name="job_type", create_constraint=True),
|
||||
|
|
|
|||
|
|
@ -141,12 +141,14 @@ def _format_ref_data_for_prompt(ref_data: dict[str, Any]) -> str:
|
|||
# Markdown table parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_markdown_table(response_text: str) -> tuple[list[dict[str, Any]], str]:
|
||||
def _parse_markdown_table(response_text: str) -> tuple[list[list[str]], str]:
|
||||
"""Parse the V25 markdown table output into structured rows.
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_rows, linguistic_summary).
|
||||
Each row is a dict with keys matching the output columns.
|
||||
Each row is a list of cell strings in column order.
|
||||
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")
|
||||
|
|
@ -174,64 +176,63 @@ def _parse_markdown_table(response_text: str) -> tuple[list[dict[str, Any]], str
|
|||
logger.warning("No markdown table found in response")
|
||||
return [], response_text.strip()
|
||||
|
||||
# Parse header row
|
||||
header_line = table_lines[0]
|
||||
headers = [h.strip() for h in header_line.split("|") if h.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("|")]
|
||||
|
||||
# Skip separator line (---|---|---)
|
||||
data_lines = []
|
||||
for tl in table_lines[1:]:
|
||||
cells = [c.strip() for c in tl.split("|") if c.strip()]
|
||||
# Skip separator rows
|
||||
if cells and all(re.match(r"^[-:]+$", c) for c in cells):
|
||||
# Skip header row and separator line, collect data rows
|
||||
data_lines: list[list[str]] = []
|
||||
for tl in table_lines[1:]: # skip header
|
||||
cells = _split_row(tl)
|
||||
# Skip separator rows (---|---|---)
|
||||
if cells and all(re.match(r"^[-:]+$", c) for c in cells if c):
|
||||
continue
|
||||
if cells:
|
||||
if any(c for c in cells): # at least one non-empty cell
|
||||
data_lines.append(cells)
|
||||
|
||||
# Map rows to dicts
|
||||
rows: list[dict[str, Any]] = []
|
||||
for cells in data_lines:
|
||||
row: dict[str, Any] = {}
|
||||
for i, header in enumerate(headers):
|
||||
row[header.lower().strip()] = cells[i] if i < len(cells) else ""
|
||||
rows.append(row)
|
||||
|
||||
linguistic_summary = "\n".join(post_table_lines).strip()
|
||||
return rows, linguistic_summary
|
||||
return data_lines, linguistic_summary
|
||||
|
||||
|
||||
def _rows_to_draft_outputs(
|
||||
rows: list[dict[str, Any]],
|
||||
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:
|
||||
Locale | Source | Option 1 | Backtranslation | Rationale | Option 2 | Backtranslation | Rationale | Option 3 | Backtranslation | Rationale
|
||||
The V25 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.
|
||||
"""
|
||||
drafts: list[DraftOutput] = []
|
||||
rankings: list[RankingDeclaration] = []
|
||||
|
||||
# Try to match by column position since headers may vary
|
||||
for i, row in enumerate(rows):
|
||||
vals = list(row.values())
|
||||
# Expected: locale, source, opt1, bt1, rat1, opt2, bt2, rat2, opt3, bt3, rat3
|
||||
# Minimum useful: 5 values (locale, source, opt1, bt1, rat1)
|
||||
|
||||
if len(vals) < 5:
|
||||
logger.warning("Row %d has too few columns (%d), skipping", i, len(vals))
|
||||
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))
|
||||
continue
|
||||
|
||||
# Extract values by position
|
||||
opt1_text = vals[2] if len(vals) > 2 else ""
|
||||
bt1 = vals[3] if len(vals) > 3 else ""
|
||||
rat1 = vals[4] if len(vals) > 4 else ""
|
||||
opt2_text = vals[5] if len(vals) > 5 else ""
|
||||
bt2 = vals[6] if len(vals) > 6 else ""
|
||||
rat2 = vals[7] if len(vals) > 7 else ""
|
||||
opt3_text = vals[8] if len(vals) > 8 else ""
|
||||
bt3 = vals[9] if len(vals) > 9 else ""
|
||||
rat3 = vals[10] if len(vals) > 10 else ""
|
||||
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 ""
|
||||
|
||||
# Clean up <br> tags to newlines
|
||||
def _clean(text: str) -> str:
|
||||
|
|
@ -376,7 +377,7 @@ class AgentSingle(BaseAgent):
|
|||
|
||||
# ── Call LLM ─────────────────────────────────────────────────
|
||||
system_prompt = self.get_system_prompt()
|
||||
llm = LLMClient()
|
||||
llm = LLMClient(model=context.job_params.llm_model or None)
|
||||
|
||||
logger.info(
|
||||
"Sending to LLM: system_prompt=%d chars, user_message=%d chars",
|
||||
|
|
@ -408,6 +409,9 @@ class AgentSingle(BaseAgent):
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class JobParams(BaseModel):
|
|||
campaign_name: str
|
||||
context_prompt: str | None = None
|
||||
tm_channels: list[str] = []
|
||||
llm_model: str | None = None
|
||||
|
||||
|
||||
class ParsedJob(BaseModel):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class JobCreate(BaseModel):
|
|||
job_ref: str | None = None
|
||||
locale_codes: list[str] = []
|
||||
tm_channels: list[str] | None = None
|
||||
llm_model: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ class JobResponse(BaseModel):
|
|||
total_token_usage: int = 0
|
||||
total_estimated_cost: float = 0.0
|
||||
tm_channels: list[str] | None = None
|
||||
llm_model: str | None = None
|
||||
locale_instances: list[LocaleInstanceResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class JobService:
|
|||
parent_job_id=data.parent_job_id,
|
||||
job_ref=data.job_ref,
|
||||
tm_channels=tm_channels,
|
||||
llm_model=data.llm_model,
|
||||
status=JobStatus.created,
|
||||
)
|
||||
db.add(job)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ def process_locale_instance(self, job_id: str, locale_code: str) -> dict:
|
|||
"campaign_name": job.campaign_name,
|
||||
"context_prompt": job.context_prompt,
|
||||
"tm_channels": job.tm_channels or [job.channel],
|
||||
"llm_model": job.llm_model,
|
||||
}
|
||||
|
||||
# Resolve file manifest for this locale
|
||||
|
|
|
|||
|
|
@ -84,16 +84,16 @@ export default function HelpPage() {
|
|||
<li>You create a job with campaign details, target locales, and a programme (Retail/Prime/Brand)</li>
|
||||
<li>You upload a source xlsx file containing the English copy</li>
|
||||
<li>You launch the job — each locale is processed in parallel (up to 4 at a time)</li>
|
||||
<li>The AI pipeline runs 6 agents per locale: validation, TM matching, ranking, transcreation, compliance checks, and formatting</li>
|
||||
<li>The AI pipeline runs a single consolidated agent per locale that handles TM matching, ranking, transcreation, and compliance in one pass</li>
|
||||
<li>You review the output with confidence tiers, backtranslations, and rationale</li>
|
||||
<li>You approve, request revisions, or add comments on each line</li>
|
||||
<li>You export the final output as a formatted xlsx file</li>
|
||||
<li>You export the final output as a formatted xlsx (Tab 1: output table, Tab 2: linguistic summary)</li>
|
||||
</ol>
|
||||
<h4>Processing time and cost</h4>
|
||||
<p>
|
||||
A typical 53-line brief takes about <strong>5-6 minutes per locale</strong> and
|
||||
costs approximately <strong>$0.50 per locale</strong>. Multiple locales run
|
||||
simultaneously, so a 12-locale job completes in about 6 minutes total.
|
||||
A typical 53-line brief takes about <strong>2-4 minutes per locale</strong> and
|
||||
costs approximately <strong>$0.30-0.50 per locale</strong> (depending on model). Multiple locales run
|
||||
simultaneously, so a 12-locale job completes in about 4 minutes total.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
|
|
@ -123,15 +123,23 @@ export default function HelpPage() {
|
|||
</tr>
|
||||
<tr>
|
||||
<td><strong>Channel</strong></td>
|
||||
<td>Value, Mass, Onsite, or Outbound — this determines which TM file is loaded</td>
|
||||
<td>Campaign channel (e.g. Mass, Value, Onsite, Outbound, UEFA, BDA)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Sub-channel</strong></td>
|
||||
<td>Optional (e.g. Radio, OLV, Display)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>AI Model</strong></td>
|
||||
<td>Claude Sonnet 4.6 (fast, cost-effective) or Claude Opus 4.6 (highest quality)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>TM Files</strong></td>
|
||||
<td>Select one or more TM channels to load. The campaign channel is auto-selected; add others (e.g. MASS as fallback) for cross-channel reference</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Locales</strong></td>
|
||||
<td>Select one or more target locales</td>
|
||||
<td>All 12 locales in a single flat grid — main and derived locales are auto-classified</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -203,32 +211,17 @@ export default function HelpPage() {
|
|||
<tr>
|
||||
<td>10%</td>
|
||||
<td>Loading Files</td>
|
||||
<td>Validating inputs and loading reference files</td>
|
||||
<td>Validating inputs and loading TM + reference files</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>25%</td>
|
||||
<td>Matching TM</td>
|
||||
<td>AI scanning Translation Memory for semantic matches</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>40%</td>
|
||||
<td>Ranking Matches</td>
|
||||
<td>Scoring and ranking TM matches by quality</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>50-80%</td>
|
||||
<td>Translating (batch X/Y)</td>
|
||||
<td>AI generating translations in batches of 15 lines</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>82%</td>
|
||||
<td>Reviewing</td>
|
||||
<td>Checking character limits, blacklist, and domain rules</td>
|
||||
<td>20-90%</td>
|
||||
<td>Transcreating</td>
|
||||
<td>Single AI agent processing all lines — TM matching, ranking, translation, and compliance in one pass</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>90%</td>
|
||||
<td>Formatting</td>
|
||||
<td>Generating the output xlsx file</td>
|
||||
<td>Formatting Output</td>
|
||||
<td>Generating the output xlsx file (Tab 1: output table, Tab 2: linguistic summary)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>100%</td>
|
||||
|
|
@ -407,8 +400,9 @@ export default function HelpPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
TM files are organized by channel (Value, Mass, Onsite, Outbound) and locale.
|
||||
The system automatically loads the correct files based on your job configuration.
|
||||
TM files are organized by channel (13 channels including Mass, Value, Onsite, Outbound,
|
||||
UEFA, BDA, and more) and locale. You can select multiple TM channels per job — the system
|
||||
loads all selected TM files into the agent's context for cross-channel reference.
|
||||
</p>
|
||||
<p>
|
||||
Admins can upload and manage these files in <strong>Admin > TM Files</strong> and{" "}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface JobFormData {
|
|||
channel: string;
|
||||
sub_channel: string;
|
||||
tm_channels: string[];
|
||||
llm_model: string;
|
||||
locales: string[];
|
||||
source_file: File | null;
|
||||
supplementary_files: File[];
|
||||
|
|
@ -31,6 +32,7 @@ const initialFormData: JobFormData = {
|
|||
channel: "",
|
||||
sub_channel: "",
|
||||
tm_channels: [],
|
||||
llm_model: "claude-sonnet-4-6",
|
||||
locales: [],
|
||||
source_file: null,
|
||||
supplementary_files: [],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ const TM_CHANNELS = [
|
|||
"PrimeGourmetGuard", "PrimeMidfunnel", "PrimeSpeed", "TheKiss",
|
||||
];
|
||||
|
||||
const LLM_MODELS = [
|
||||
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", description: "Fast, cost-effective" },
|
||||
{ value: "claude-opus-4-6", label: "Claude Opus 4.6", description: "Highest quality" },
|
||||
];
|
||||
|
||||
const SUB_CHANNELS = [
|
||||
"TV_OLV",
|
||||
"RADIO",
|
||||
|
|
@ -219,6 +224,30 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Model</Label>
|
||||
<Select
|
||||
value={data.llm_model}
|
||||
onValueChange={(val) => onChange({ llm_model: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}{" "}
|
||||
<span className="text-gray-400">— {m.description}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-400">
|
||||
Sonnet is faster and cheaper. Opus produces the highest quality output.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TM File Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function StepReview({ data, onBack }: StepReviewProps) {
|
|||
channel: data.channel.toLowerCase(),
|
||||
sub_channel: data.sub_channel ? data.sub_channel.toLowerCase() : null,
|
||||
tm_channels: data.tm_channels.length > 0 ? data.tm_channels.map(c => c.toLowerCase()) : undefined,
|
||||
llm_model: data.llm_model || undefined,
|
||||
locale_codes: data.locales,
|
||||
context_prompt: data.context_override || undefined,
|
||||
});
|
||||
|
|
@ -152,6 +153,14 @@ export function StepReview({ data, onBack }: StepReviewProps) {
|
|||
{data.sub_channel || "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">
|
||||
AI Model
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-0.5">
|
||||
{data.llm_model === "claude-opus-4-6" ? "Claude Opus 4.6" : "Claude Sonnet 4.6"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">
|
||||
TM Files
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export interface CreateJobRequest {
|
|||
channel: string;
|
||||
sub_channel?: string | null;
|
||||
tm_channels?: string[];
|
||||
llm_model?: string;
|
||||
locale_codes: string[];
|
||||
context_prompt?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue