# Round 11 — Tower Stations J01-J04 · Reception Quartet

**Date**: 2026-04-30
**Time**: 1.5h budget
**Status**: ✅ COMPLETE — first 4 floors specified
**Round Type**: Tower Floors (B series begins)

---

## 🎯 הקומות בהן עוסק סבב זה

המגדל מתחיל בכניסה. ההודעה מגיעה אל **הלובי**, ועוברת **4 תחנות קליטה** לפני שמועברת ל-floors הגבוהים יותר. כל תחנה היא LLM agent ייעודי + cache + fallback + observability.

| # | Station | תפקיד | Latency target |
|---|---|---|---|
| **J01** | Reception | קולט raw input · validates · creates flow_id | <50ms |
| **J02** | Language Detector | זיהוי שפה (he/en/ar) · confidence | <200ms |
| **J03** | Triage | רמת דחיפות · breaking? · embargo? | <500ms |
| **J04** | Desk Router | איזה desk (security/politics/economy/...) | <800ms |

**Cumulative latency** עד שהמסמך עובר ל-J05: ~1.5 sec. לאחר זה מתחיל 5W ב-parallel.

---

## 🏛️ J01 · Reception (Lobby Floor)

### תפקיד
ה-**Lobby** של המגדל. כל hodaạ מגיעה לכאן ראשונה. תפקידו:
1. **Validate**: text > 10 chars · text < 100K chars · UTF-8 valid
2. **Sanitize**: strip HTML · normalize whitespace · detect script injection
3. **Flow creation**: INSERT into `press_flows` table → `flow_id`
4. **Tenant scoping**: extract `X-Tenant-ID` header → set RLS context
5. **Audit**: log incoming request to `press_flow_events` (event=`reception_received`)

### Implementation
**לא LLM**. זה layer דטרמיניסטי בלבד — Python validation + DB INSERT.

```python
# /opt/maariv-press/api/routes/press_v3.py
@router.post("/press/v3/stream")
async def press_stream(request: Request, body: PressInput):
    # J01 — Reception
    if len(body.text) < 10:
        raise HTTPException(400, "Text too short")
    if len(body.text) > 100_000:
        raise HTTPException(400, "Text too long (max 100K chars)")

    # Sanitize
    clean_text = bleach.clean(body.text, tags=[], strip=True)
    clean_text = re.sub(r'\s+', ' ', clean_text).strip()

    # Tenant scoping
    tenant_id = int(request.headers.get('X-Tenant-ID', '0'))
    if not tenant_id:
        raise HTTPException(401, "Missing tenant")

    # Flow creation
    flow_id = await db.insert_press_flow(tenant_id=tenant_id, source_text=clean_text)

    # Audit
    yield _emit("reception_received", {"flow_id": flow_id, "char_count": len(clean_text)})

    # Continue to J02...
```

### A2UI emission
```json
{"version":"v0.9","createSurface":{"surfaceId":"press-flow-{flow_id}","catalogId":"...","theme":{...}}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/meta/state","value":"received"}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/input/length","value":1247}}
```

### Cost
- 0 LLM tokens
- 0 vector queries
- 1 DB INSERT
- **Cost: ~$0.000001 per request**

---

## 🌐 J02 · Language Detector

### תפקיד
זיהוי שפת המסמך וכיוון (LTR/RTL). חשוב כי:
- מסמכים בעברית/ערבית = RTL → A2UI theme.rtl=true
- BBC tenant מקבל אנגלית → אם מקבלים עברית = error/warning
- בוחר את ה-LLM prompts המתאימים (Hebrew vs English vs Arabic)

### Implementation
**שני שלבים**:

#### Stage 1: Heuristic (95% מהמקרים, free)
```python
def detect_lang_heuristic(text: str) -> tuple[str, float]:
    """Fast char-set detection · returns (lang, confidence)"""
    hebrew_chars = len(re.findall(r'[\u0590-\u05FF]', text))
    arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
    latin_chars = len(re.findall(r'[a-zA-Z]', text))
    total = hebrew_chars + arabic_chars + latin_chars

    if total == 0: return ("unknown", 0.0)
    if hebrew_chars / total > 0.6: return ("he", hebrew_chars / total)
    if arabic_chars / total > 0.6: return ("ar", arabic_chars / total)
    if latin_chars / total > 0.7: return ("en", latin_chars / total)
    return ("mixed", 0.0)
```

#### Stage 2: LLM fallback (5% — mixed content)
אם confidence < 0.6 → קריאה ל-Gemini Flash:
```
prompt: "What is the primary language of this text? Reply with ISO-639-1 code only.\n\nText:\n{text}"
expected: "he" | "en" | "ar" | "fr" | ...
cost: ~$0.0001
```

### A2UI emission
```json
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/input/language","value":"he"}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/input/direction","value":"rtl"}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/input/lang_confidence","value":0.97}}
```

### Cost
- Heuristic: 0 cost
- LLM fallback (5% of cases): ~$0.0001 each
- **Average per request: ~$0.000005**

---

## 🚨 J03 · Triage (Urgency + Embargo Detection)

### תפקיד
שליפת signals של דחיפות וsensitivity:
- **Urgency**: breaking / urgent / standard / archive
- **Embargo**: האם המסמך מסומן "חסוי עד שעה X"?
- **Sensitivity**: military_security / personal_info / minors / minor_victims

זה **חוסם שלב**: אם sensitivity = `personal_info_unmasked` → flag לעורך לבדיקה אנושית.

### Implementation
LLM Agent + rule-based hybrid:

```python
SYSTEM_PROMPT = """You are a press desk triage agent for Israeli media.
Analyze the press release and output JSON:
{
  "urgency": "breaking" | "urgent" | "standard" | "archive",
  "urgency_reasons": [list of strings],
  "embargo": null | { "until": ISO_TIMESTAMP, "source": "explicit_text" },
  "sensitivity": {
    "military": 0.0-1.0,
    "personal_info": 0.0-1.0,
    "minors": 0.0-1.0,
    "victims": 0.0-1.0
  },
  "flags": [list of warning strings]
}

Rules:
- If text mentions IDF military operations → military: 0.8+
- If names + ages of children → minors: 0.9+
- If "embargoed until X" or "להפצה רק לאחר Y" → embargo set
- If "killed", "wounded" + names → victims: 0.7+
"""

# Gemini 2.5 Flash with structured output
response = await gemini.generate(
    system=SYSTEM_PROMPT,
    user=text,
    response_format={"type": "json_schema", "schema": TRIAGE_SCHEMA}
)
```

### A2UI emission
```json
{"version":"v0.9","updateComponents":{"surfaceId":"...","components":[
  {"id":"urgency-badge","component":"Badge","color":"breaking","text":"BREAKING","pulse":true}
]}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/triage","value":{...}}}
```

אם urgency = "breaking" → ה-frontend מוסיף **animation pulse אדום** ב-stepper. ה-LLM לא יודע על pulse — ה-CSS יודע (data-attribute `[data-urgency="breaking"]`).

### Cost
- 1 Gemini 2.5 Flash call · ~500 input + ~200 output tokens
- **Cost: ~$0.0003 per request**

---

## 🗂️ J04 · Desk Router

### תפקיד
מצא את ה-**desk** הנכון מתוך 11:
1. **breaking** — חדשות מיידיות
2. **security** — צבא, ביטחון, טרור
3. **politics** — כנסת, ממשלה, מפלגות
4. **economy** — בורסה, מאקרו, מטבעות
5. **sports** — כדורגל, NBA, אולימפיאדה
6. **tech** — טכנולוגיה, סייבר, AI
7. **entertainment** — קולנוע, מוזיקה, תרבות
8. **health** — רפואה, בריאות הציבור
9. **social** — חברה, רווחה, חינוך
10. **gallery** — גלריות אמנות, אופנה
11. **archive** — היסטוריה, ארכיון

ה-desk קובע:
- **Color** של ה-flow ב-UI (`var(--desk-{name})`)
- **Writers pool** (J13-J16) — איזה writer agent יוקצה
- **Vector index** (J10) — איזה pgvector index לחפש
- **Distribution channels** (J39-J44) — TBD

### Implementation
**Two-stage**:

#### Stage 1: Classifier model (fast, 100ms)
fine-tuned BERT classifier על 50K כתבות מעבר. מחזיר `top_3 desks + scores`.

#### Stage 2: LLM tie-breaker (LLM, only if top_2 close)
אם `top_1.score - top_2.score < 0.15` → קריאה ל-Gemini עם 2 הdesks המובילים + הטקסט → final decision.

### A2UI emission
```json
{"version":"v0.9","updateComponents":{"surfaceId":"...","components":[
  {"id":"desk-badge","component":"Label","variant":"filled","color":"security","text":"בטחון"}
]}}
{"version":"v0.9","updateDataModel":{"surfaceId":"...","path":"/classify/desk","value":{
  "primary":"security",
  "scores":{"security":0.87,"politics":0.09,"breaking":0.03},
  "tie_breaker_used":false
}}}
```

### Cost
- BERT inference: ~$0.00001 (self-hosted on EX63 GPU)
- LLM tie-breaker (15% of cases): ~$0.0002
- **Average per request: ~$0.00004**

---

## 📊 Reception Quartet · Total

| Station | Latency | Cost | Type |
|---|---|---|---|
| J01 Reception | <50ms | $0.000001 | Deterministic |
| J02 Language | <200ms | $0.000005 | Heuristic + LLM fallback |
| J03 Triage | <500ms | $0.0003 | LLM (Gemini Flash) |
| J04 Desk Router | <800ms | $0.00004 | BERT + LLM tie-breaker |
| **Total** | **<1.5s** | **~$0.00035** | |

ב-100K הודעות ביום (גודל newsroom ארצי):
- **Latency p95**: 1.2 sec from paste to "ready for 5W"
- **Daily cost (Reception only)**: $35
- **Monthly**: $1,050

---

## 🎯 ההכרעות הקריטיות

### 1. למה 4 stations ולא station אחד גדול?

3 סיבות:
- **Observability**: כל station יכול להיכשל עצמאית. נראה ב-Lobby dashboard איזה station נתקע.
- **Caching**: J04 (Desk Router) cacheable per-text-hash. אם אותו טקסט נשלח שוב → 0 latency, 0 cost.
- **Replacement**: BBC רוצים algorithm אחר ל-Triage? מחליפים רק J03, השאר נשארים.

### 2. למה J02 לפני J03 ולא הפוך?

הtriage prompt תלוי שפה. אם המסמך בערבית, הprompt עברית לא יעבוד טוב. **Language first**.

### 3. למה Desk Router ב-station נפרד ולא חלק מ-Triage?

Triage = "כמה דחוף". Desk Router = "באיזה תחום". שני concerns שונים. Triage יכול להיות "breaking" + Desk = "sports" (אסון בכדורגל). שניהם עצמאיים.

### 4. Embargo handling

אם J03 מצא embargo → ה-flow ממתין במצב `pending_embargo`:
- כל station אחר עובד עליו (5W extraction, context search, writing) — הכל מוכן
- אבל **Distribution (J39-J44) חסום** עד `embargo.until`
- ב-`embargo.until` → cron triggers `unembargo` event → distribution starts

זה ייחוד של newsroom workflow — לא קיים ב-A2UI base catalog.

---

## 🔌 A2UI Catalog Entries לקומות J01-J04

```json
{
  "components": {
    "ReceptionStatus": {
      "type": "object",
      "description": "J01 status indicator in Lobby",
      "properties": {
        "state": { "type": "string", "enum": ["idle", "received", "validating", "valid", "rejected"] },
        "charCount": { "$ref": "#/$defs/DynamicNumber" },
        "errorMsg": { "$ref": "#/$defs/DynamicString" }
      }
    },
    "LanguageBadge": {
      "type": "object",
      "description": "J02 detected language indicator",
      "properties": {
        "lang": { "type": "string" },
        "direction": { "type": "string", "enum": ["ltr", "rtl"] },
        "confidence": { "$ref": "#/$defs/DynamicNumber" }
      }
    },
    "UrgencyIndicator": {
      "type": "object",
      "description": "J03 triage urgency display",
      "properties": {
        "level": { "type": "string", "enum": ["breaking", "urgent", "standard", "archive"] },
        "pulseAnimation": { "type": "boolean", "default": false },
        "reasons": { "type": "array", "items": { "type": "string" } }
      }
    },
    "DeskBadge": {
      "type": "object",
      "description": "J04 desk classification with color",
      "properties": {
        "desk": { "type": "string", "enum": ["breaking","security","politics","economy","sports","tech","entertainment","health","social","gallery","archive"] },
        "confidence": { "$ref": "#/$defs/DynamicNumber" },
        "tieBreakerUsed": { "type": "boolean" }
      }
    }
  }
}
```

---

## ✅ Closure

- [x] J01 Reception spec'd (deterministic, <50ms)
- [x] J02 Language Detector (heuristic + LLM fallback)
- [x] J03 Triage (Gemini structured output)
- [x] J04 Desk Router (BERT + LLM tie-breaker)
- [x] A2UI catalog entries defined
- [x] Cost + latency budget calculated
- [x] Embargo workflow documented

✅ **Round 11 closed. 4 floors of 44+10 documented.**

---

## 🛣️ Next: Round 12 — J05-J09 (5W Parallel Agents)
