ארכיטקטורת ה-Airport Investment Intelligence Agent

זהו לא ה-scaffold הגנרי — זו ה-assignment האמיתית שהוגשה ל-Wonderful ב-19.8.2026. דומיין: אילו שדות תעופה בארה"ב הכי משתלם להשקיע בהם בהרחבה/שיפוץ. מסמך זה מיועד להגנה עליה בעל-פה, מספר וקובץ, ב-20.8.2026.

Python · FastAPI 515 → 144 airports 5 criteria, 11 tools Real STT/TTS voice + barge-in 296 tests 26-task eval harness

1מבוא

מה הסוכן עושה, ולמי הוא מיועד.

כלי שיחתי לאנליסט השקעות בתשתיות תעופה, על פני כל היקום של 515 שדות התעופה ב-FAA Commercial Service Enplanements — לא סט מצומצם לדוגמה. הוא עונה על ארבע צורות שאלה שונות מהותית (לא ארבע ניסוחים לאותה שאלה): דירוג מסונן, השוואת זוגות, אגרגציה על ישות בודדת, ומדד מחושב עם הסבר סיבתי — על ידי קריאה לפונקציות Python דטרמיניסטיות והסבר התוצאה שלהן. הוא לעולם לא מחשב ציון, אחוז, או דירוג בעצמו.

הממצא המרכזי: תחת חמשת הקריטריונים המשוקללים לכיוון "פוטנציאל צמיחה" (ולא "גודל נוכחי"), השדה הכי גדול לא מנצח אוטומטית — LAX מדורג 67 מתוך 144 שדות זכאים, והשניים המובילים בפועל (Nashville ו-Denver) מרוחקים 0.30% בלבד, כשכל אחד מחמשת המשקלים יכול להפוך את הסדר בשינוי של 5%.

ארבע צורות השאלה

צורהדוגמה מהבריףפרימיטיבלמה לא ניתן למחזר את מסלול הדירוג
דירוג מסונןמועמדים להרחבה בניו-אינגלנדfind_itemscompare_items
השוואה + ישות עמומהLA מול Santa Anaresolve_entitycompare_items"LA" עמום אמיתי — הרזולבר חייב לדעת להגיד את זה
אגרגציה על ישות בודדת% טיסות ארוכות-טווח מ-Anchorageaggregate_recordsחלק יחסי בתוך שדה אחד, לא השוואה בין שדות
מדד נגזר + סיבתיותביקוש בלתי-ממומש ב-SFO, ולמהestimate_derived_metricהכמות לא קיימת באף מקור נתונים — חייבת מודל, עם הנחות-יסוד מוצמדות
הסוכן במכוון לא מודל feasibility של הרחבה (קרקע, היתרים, הסכמה פוליטית), לא משתמש בסטטוס תפעולי חי (מזג אוויר, עצירות קרקע) כקלט לדירוג, ולא טוען שיודע "מי הכי טוב באמת" כשהנתונים לא מכריעים — ראש הדירוג מדווח כתיקו כשהוא תיקו, לא מוחלק לכדי מנצח כוזב.

2מספרים לזכור

גיליון-הצצה מהיר לפני ההגנה — כל מספר כאן צריך לצאת בעל-פה, בלי היסוס.

515 → 144שדות בסה"כ → זכאים לדירוג (hub class L/M/S)
25/25/20/15/15משקלי חמשת הקריטריונים
67 / 144דירוג LAX בפועל
0.30%הפער בין BNA ל-DEN בראש
5%שינוי משקל שהופך את המנצח — בכל אחד מחמשת הקריטריונים
0.76–0.92Kendall tau כשמכפילים/מחצים משקל בודד
0.89 / 0.85r בין capacity_pressure ל-absolute_scale (515 / 144)
11כלים חשופים למודל
296טסטים, clone נקי + אפס מפתחות
26משימות ב-eval harness
24/26 = 92%שיעור הצלחה, gpt-4o-mini אמיתי
90%הסכמת LLM-judge מול תיוג אנושי (n=10)
$0.026עלות 26 המשימות (קריאות הסוכן בלבד)
14 / 515שדות בלי נתון אוכלוסייה (פוארטו ריקו + איים)
שני ממצאי הגנה קריטיים מה-review pass האחרון (19.8, ce18c49):
  1. רצפת הנרמול של capacity_pressure הייתה תקועה על ערך ישן (201,438.53 במקום 287,264.425 — האחוזון ה-5 האמיתי של ה-144 הזכאים). זה שינה את כל המספרים למעלה: LAX 69→67, הפער 0.41%→0.30%, הקריטריון הרגיש ביותר catchment_monopolytraffic_growth, וטווח הפיכת-המנצח 5–10%→5% קבוע. נמצא ע"י test_real_dataset_scores_are_pinned_to_known_values. ההחלטה: לתקן את הקבוע, לא לדווח כפער — כי זו טעות, לא trade-off.
  2. ערכי ה-r בטבלת הקריטריונים ב-DESIGN_DOC.md נמדדו במקור על כל 515 השדות, לא על ה-144 המדורגים — והדוח לא ציין זאת. על האוכלוסייה המדורגת המספרים שונים מהותית (traffic_growth −0.02→−0.29, catchment_monopoly −0.05→−0.29, regional_demand_growth +0.18→+0.02, capacity_pressure 0.89→0.85). שתי האוכלוסיות מוצגות כעת. אם נשאלים: טענת אי-הקורלציה שורדת בכל אחת מהן, אבל האוכלוסייה המדורגת היא זו שרלוונטית — והיא זו שמוצגת כעת.

3עקרונות יסוד

כללי ברזל שחוזרים בכל שכבה בקוד, ושכדאי לדעת לצטט בעל-פה.

אפס I/O, אפס LLM בליבה הדטרמיניסטית

scoring.py, entity_resolution.py, analytics.py ו-runway_geometry.py לא עושים אף קריאת רשת, קריאת קובץ, או קריאת משתנה סביבה — נאכף גם ע"י מבחן mutation, לא רק במוסכמה.

Hand-rolled agent loop, בלי framework

אין LangChain, אין LangGraph. הלולאה (agent_loop.py) היא כ-70 שורות while פשוט על ספק אחיד — שליטה מלאה במסלול, בטרמינציה, ובעקבות ניפוי השגיאות.

שגיאת כלי היא נתון, לא קריסה

כל חריגה שנזרקת בתוך כלי נתפסת ומומרת ל-{"error": ...} שחוזר למודל כטקסט. תפיסה זו נבדקת גם דרך agent_loop.py וגם דרך /chat שתופס MaxTurnsExceeded ומחזיר תשובה חלקית במקום 500.

לעולם לא להמציא מספר

NEVER_COMPUTE_RULE ב-system_prompt.py אוסר על המודל לחשב, ואוכף בפועל: כל כלי מחזיר פירוק לרכיבים (ערך גולמי, ציון מנורמל, משקל, תרומה) — לעולם לא מספר בודד.

מזהה שגוי נכשל בקול רם, לא בשקט

NEVER_INVENT_IDS_RULE מנחה את המודל לקרוא ל-resolve_entity — אבל זה לא אטום: תמלולי eval אמיתיים מראים ש-gpt-4o-mini לפעמים מספק קוד ידוע (LAX) ישירות. הערבות בפועל: מזהה שגוי גורם ל-compare_items לזרוק UnknownItemError — נכשל, לא מדרג בטעות.

פלט של כלי הוא תמיד "נתון לא מהימן"

כל תוצאה שמגיעה מכלי נעטפת (wrap_untrusted) בתגית <untrusted_data> לפני שהיא חוזרת לשיחה, וסרוקה (scan_for_injection) לתבניות prompt-injection נפוצות — פילטר regex דטרמיניסטי, לא קריאת LLM שנייה.

4ארכיטקטורה כללית — שכבות

מבט-על מלמעלה למטה: מהממשק למשתמש ועד לתשתית.

ממשק משתמש (Chat + Voice UI) static/index.html · static/markdown.js · static/voice.js · app/cli.py
שכבת ה-API app/main.py (FastAPI + SSE) · app/voice_api.py
Agent Loop + היסטוריית שיחה app/agent_loop.py · app/conversation.py
שכבת ה-Tools (1675 שורות — הקובץ הגדול ביותר) app/tools.py — 11 כלים, כל אחד עם פירוק לרכיבים
ליבה דטרמיניסטית (אפס I/O) scoring.py · entity_resolution.py · analytics.py · runway_geometry.py
מקור הנתונים + חוצה-שכבות dataset.py · guardrails.py · system_prompt.py
שכבת ה-Providers providers/llm/ · providers/stt/ · providers/tts/
קונפיגורציה ותשתית config.py · .env · data/

זהו כבר לא ה-scaffold הגנרי — כל שכבה כאן מלאה בדומיין אמיתי: 515 שדות תעופה, חמישה קריטריונים מוגנים, ושתי שכבות קול שתיהן פועלות בפועל.

5זרימת בקשה טיפוסית

מה קורה בפועל, צעד אחר צעד — גם לשאלה כתובה וגם לשאלה מדוברת.

1. הודעת משתמש
POST /chat/stream (SSE, מה שה-UI באמת משתמש בו) או POST /chat (תשובת JSON יחידה) — שניהם ב-app/main.py
2. run_agent() ב-agent_loop.py
בונה רשימת הודעות ומעביר ל-provider הפעיל
3א. אין tool calls
התשובה הסופית זורמת כאירוע done — סוף הלולאה
3ב. יש tool calls
כל קריאה מנותבת דרך TOOL_REGISTRY ל-app/tools.py, ונדחפת ל-UI כאירוע tool_call ברגע שהיא מסתיימת
4. הכלי שולף נתונים וקורא לליבה הדטרמיניסטית
dataset.py מספק את הנתונים; scoring.py / analytics.py / runway_geometry.py מחשבים — תמיד פירוק לרכיבים
5. עטיפת "נתון לא מהימן" + בדיקת הזרקה
guardrails.wrap_untrusted() + scan_for_injection() לפני שהתוצאה חוזרת לשיחה
6. חזרה לצעד 2, עד תשובה סופית או max_turns
ברירת מחדל 6 — MaxTurnsExceeded נושא את התמלול החלקי ואת יומן הכלים

ומה קורה כששאלה נאמרת בקול

1. מיקרופון בדפדפן + זיהוי-קצה מקומי
static/voice.js — דגימה ל-16kHz, VAD מבוסס אנרגיה
2. POST /voice/transcribe
בשרת → STT (gpt-4o-mini-transcribe) → טקסט
3. אותו POST /chat/stream בדיוק
אין /voice/chat נפרד — מכאן ואילך זו בדיוק אותה זרימה כמו טקסט
4. POST /voice/speak
התשובה מסונתזת משפט-אחר-משפט (gpt-4o-mini-tts / Google Neural2) ומתנגנת
5. הפרעה (barge-in) → POST /voice/interrupt
עוצר נגינה, זונח אודיו שלא נוגן, וקוצץ את התמלול השמור (conversation.py) למה שבאמת נשמע

6קריטריוני הדירוג — הרכיב הכי-מוגן בכל הפרויקט

חמישה קריטריונים, משקלים 25/25/20/15/15, על סט זכאות של 144 מתוך 515 שדות.

הבעיה שהמסגור הזה פותר

הבריף שואל אילו שדות "renovations will be most profitable based on increased flight and passenger capacity" — זו שאלה על עתודה בלתי-ממומשת, לא על גודל נוכחי. ציון מורכב על תעבורה נוכחית ידרג את השדות הכי גדולים ראשונים ולא יגיד כלום על האם הרחבה שלהם באמת משתלמת. הקריטריונים למטה נבחרו כדי לשקף עתודה, לא גודל.

קריטריוןמשקלמקורלמה זה מייצג עתודה
traffic_growth25FAA, שינוי % CY2024→CY2025לחץ שכבר עולה — r=−0.02 (515) / −0.29 (144) עם גודל
regional_demand_growth25אוכלוסיית מחוז, CAGR 2022→2025סימן הביקוש היחיד באזור — r=+0.18 (515) / +0.02 (144)
catchment_monopoly20מרחק מתחרה משדה-שירות קרוב ביותרהאם ביקוש יכול "לברוח" — r=−0.05 (515) / −0.29 (144)
capacity_pressure15נוסעים ÷ מספר מסלולי-נשאפרוקסי הצפיפות היחיד הזמין; r=0.89 (515) / 0.85 (144) עם absolute_scale — מוצהר, לא מוסתר
absolute_scale15נוסעים, CY2025 ראשוני"גודל הפרס" — נשאר תחת תקרת ה-25% שהעיצוב קבע לעצמו
אפקט נטו: שני הקריטריונים חד-משמעית עתידיים ולא-מתואמים (traffic_growth + regional_demand_growth) נושאים 50% מהמשקל; שני הקריטריונים "מגמת-גודל" (capacity_pressure + absolute_scale, מתואמים ביניהם) נושאים 30%. בדיקת שפיות: LAX מדורג 67 מתוך 144, לא ראשון.

נרמול ונתונים חסרים

כל קריטריון מנורמל min-max ל-[0,1] מול חיתוך אחוזון 5/95 של הסט הזכאי (144), חוקי אחד עקבי לכל הקריטריונים — לא log-transform נפרד לכל קריטריון עקום. נתוני תעופה ציבוריים "מרוטשים" (ragged) — 14 מתוך 515 שדות (פוארטו ריקו ואיים) חסרי נתון אוכלוסייה. scoring.py מוריד את הרכיב החסר ומנרמל מחדש את שאר המשקלים; מתחת לסף כיסוי (0.5, לא כולל) השדה נפסל מהדירוג לגמרי ומדווח בנפרד — ציון שנבנה על שאריות גרוע מהשמטה כנה.

רגישות משקלים — כמה המשקלים באמת משנים

פלט אמיתי של weight_robustness_report על 144 השדות, 19.8.2026 (לאחר תיקון רצפת הנרמול):

baseline_top: BNA (Nashville, 0.6333)  — runner-up: DEN (Denver, 0.6314)
most_sensitive_criterion: traffic_growth (flips winner at 0.95x)

criterion                current_weight  flip_factor
traffic_growth           25              0.95
regional_demand_growth   25              0.95
catchment_monopoly       20              0.95
capacity_pressure        15              1.05
absolute_scale           15              1.05

כל חמשת הקריטריונים בתיקו, לא רק זה שמופיע בשם. כל flip_factor מרוחק בדיוק 5% מ-1.0. most_sensitive_criterion בוחר תווית אחת מתוך תיקו חמישייתי (min() על |factor − 1.0|, ההתאמה הראשונה מנצחת) — זה כבר השתנה פעם אחת (catchment_monopolytraffic_growth) בעקבות תיקון נתונים שלא נגע במשקל של אף קריטריון. הממצא המרכזי הוא לא באג להסביר — מקום #1 לא מכריע. זו התשובה הכנה ל"למה המשקלים האלה": הצורה הכללית של הדירוג היא מה שהמשקלים מגנים עליו, לא #1 ספציפי.

קריטריוןτ ב-×0.5שינוי מנצח ב-×0.5τ ב-×2.0שינוי מנצח ב-×2.0
traffic_growth0.841BNA→DEN0.764BNA→PVU
regional_demand_growth0.858BNA→DEN0.837BNA→XNA
catchment_monopoly0.837BNA→DEN0.769לא
capacity_pressure0.907לא0.851BNA→DEN
absolute_scale0.915לא0.875BNA→DEN

Kendall tau נשאר 0.76–0.92 גם כשמכפילים או מחצים משקל בודד — הסדר הכללי הרבה יותר יציב מ-#1. BNA ו-DEN בתיקו מסיבות הפוכות (BNA מנצח על צמיחה+מונופול, DEN על גודל+צפיפות) — compare_items מדווח tied_at_top/decisive מפורשות כשהשניים הראשונים בטווח 0.005, וכלל 6 ב-system_prompt.py מחייב את המודל להציג תיקו כתיקו.

שער הזכאות — למה 144 ולא 515

דירוג על כל 515 השדות הכניס שדות עם אלפי נוסעים בודדים לראש 50 — פרוקסי-רעש של צמיחה-באחוזים על בסיס אפסי (שדה אחד ב-+126,403% שנתי על 37,951 נוסעים). הזכאות מוגבלת ל-FAA hub class L/M/Sפילטר דירוג, לא חיתוך נתונים: השדות עדיין ניתנים לשליפה בשם דרך find_items/resolve_entity. נבחרה סיווג הרגולטור עצמו על פני רצפת נוסעים שהומצאה — "השתמשתי בהגדרה של הרגולטור למה זה שדה ראשי", לא במספר עגול שנבחר כדי שהתוצאה תיראה נכון.

שער הזכאות היה חור אמיתי שתוקן: השער חי בתוך compare_items בלבד — שלושת כלי הדירוג האחים (rank_by_priorities, analyze_weight_sensitivity, weight_robustness_report) שיחזרו בדיוק את כשל New Bedford Regional שהשער נועד למנוע. שער שהמודל צריך לזכור לעבור דרכו הוא לא כלל, הוא הצעה — עכשיו הוא בכל ארבעת הכלים.

7צנרת הנתונים

חמישה מקורות ציבוריים, ללא מפתח, נבנים פעם אחת ל-data/ — לא ברגע הבקשה.

מקורמספק
OurAirportsזהות, גיאוגרפיה, גיאומטריית מסלולים
FAA Commercial Service Enplanementsנפח נוסעים + סיווג hub class
US Census PEP — אוכלוסיית מחוזהסימן היחיד בצד-הביקוש
BTS T-100 Segment Summary, מסונן ל-ANCפרוקסי טיסות-ארוכות לשאלה 3
FAA NAS Status (חי)סטטוס תפעולי — מחוץ למסלול הדירוג במכוון
app/dataset.py
המקום היחיד שקורא מ-data/processed_data/ בזמן ריצה — כל שאר המודולים נשארים טהורים וניתנים לבדיקה בלי fixtures
data/refresh_data.py
שוזר מחדש את כל חמשת המקורות — כל שליפה אידמפוטנטית וללא מפתח
היקף
הורחב מסט מתויג של 27 שדות לכל היקום — 515 — של ה-FAA. זה חשף (ותיקן) באג הצטלבות שקט: קודי LocID של ה-FAA התנגשו עם שדות זרים באותו קוד תלת-אותי (14 שדות, כולל מישיגן שהתמפה לאיסטנבול)
שתי חלונות צמיחה
2020→2025 וגם 2022→2025 — החלון המלא "אופה" את זעזוע הנדידה של הקורונה כאילו הייתה מגמה (SFO קורא −0.50%/שנה בחלון המלא, +0.51%/שנה בחלון האחרון)
מחוז, לא עיר/CBSA
צירוף FIPS מדויק ללא צורך להמציא גבולות — מגבלה מוצהרת: מחוז ≠ אזור-קליטה אמיתי (Suffolk County של בוסטון ~792K מול מטרו אמיתי ~4.9M) — סימן מגמה, לא גודל שוק
שאלה 3 ("ארוך-טווח") היא פרוקסי
חלק בין-לאומי/מקומי מתוך departures, לא סף מרחק אמיתי — נתוני BTS T-100 ברמת המסלול חסומים בכל נקודת גישה ציבורית שנבדקה (כולל דרך ה-API של Socrata, לא רק דף הקטלוג)

8רכיבי הליבה

קובץ אחר קובץ — מה כל אחד עושה ואיזה עיקרון הוא אוכף.

app/agent_loop.py

לולאת ה-Agent

הלולאה עצמה — while פשוט, כ-70 שורות, בלי framework.

מנגנון
שולח הודעות ל-provider → אם יש tool_calls, מריץ אותם דרך TOOL_REGISTRY, עוטף תוצאה כ"נתון לא מהימן", מוסיף להיסטוריה, לולאה שוב
הגנת סיום
max_turns (ברירת מחדל 6) → MaxTurnsExceeded עם תמלול חלקי ויומן כלים, לא crash חשוף

app/scoring.py

ליבה דטרמיניסטית

מנוע הדירוג המשוקלל — rank_items(), Criterion.normalize(), חיתוך אחוזון, נרמול-מחדש של משקלים מעל coverage_threshold. הקובץ הכי "ניתן להגנה" בכל הפרויקט.

ניתוח רגישות
sensitivity_analysis(), find_weight_flip_point() — מאפשרים "מה קורה אם משקל משתנה" כמותית, במקום להשאיר את זה לתחושת בטן

app/entity_resolution.py

ליבה דטרמיניסטית

Jaro-Winkler + Soundex (מומשו ידנית, ללא תלות חיצונית), פלוס שכבת metro ופלוס fallback מרחק-עריכה מוגבל לקודים קצרים.

METRO_AIRPORTS
"LA" אינו שדה אחד — זה חמישה. אין עמודה ציבורית שמקודדת שווקי-metro משותפים, אז זה ה-lookup היחיד שנכתב ביד בקובץ
קוד קצר, למשל "LBG"
שיבוש של "LGB" לא נמצא כי חלון ההתאמה של Jaro-Winkler קורס באורך 3 — תוקן צר עם מרחק-עריכה מוגבל לקודים בצורת-קוד בלבד, כדי לא לפתוח מחדש את מקרה ה-false-positive שהצדיק את שומר האורך המקורי
decisive: bool
שני תנאים יחד: ביטחון מעל סף מוחלט וגם פער מהמועמד השני

app/runway_geometry.py

ליבה דטרמיניסטית — חדש

מחשב הפרדה בין מסלולים מקבילים ופגיעה בקיבולת נחיתה ממקורי מיקום מסלול ציבוריים, לכל שדה. מזין את איבר "קיבולת מדוכאת-מזג-אוויר" ב-estimate_unmet_demand.

ולידציה
test_sfo_parallel_separation_matches_the_published_figure — ההפרדה המחושבת ל-SFO (746.8 רגל) תואמת את הנתון הפומבי הידוע (~750 רגל)

app/analytics.py

ליבה דטרמיניסטית

שלוש הצורות שהן לא דירוג.

find_items()
סינון (סמנטיקת AND) — מפתח לא-מוכר מוחזר כ"אין התאמות" עם רשימת שדות אמיתיים, לא מתעלם ממנו
aggregate_records()
חלק יחסי/ממוצע/ספירה על תת-רשומות של ישות בודדת. קטגוריה ריקה וקטגוריה לא-מוכרת הן כשלים שונים — unknown_category + known_categories + category_semantics מסבירים איזה פרוקסי אמיתי מתאים
estimate_derived_metric()/estimate_unmet_demand()
מדד מחושב עם גורמים תורמים שסכומם שווה לערך עצמו — ההסבר לא יכול לסטות מהמספר. צמיחה שלילית לא נזקפת כעתודה עתידית (test_declining_growth_is_clamped_not_credited_as_headroom)

app/tools.py

שכבת חיבור — 1675 שורות

המקום היחיד שמחבר את הליבה הדטרמיניסטית למה שהמודל רואה.

11 הכלים החשופים למודל

כליעונה עלקורא מ-
find_items"אילו שדות תואמים X" (סינון)dataset.py
compare_items"השווה/דרג שדות" — כאן חי שער הזכאותscoring.py
rank_by_priorities"אכפת לי מצמיחה יותר מצפיפות" (משקלים מותאמים)scoring.py
analyze_weight_sensitivity"כמה משנה שינוי משקל X"scoring.py
weight_robustness_report"כמה יציב הדירוג" (תיקו-כמעט, τ)scoring.py
list_criteria"מה המשקלים שלך" (בלי צורך בפריטים)קבועי scoring.py
resolve_entity"LA", "Ankorage" → מזהי פריטentity_resolution.py
get_item_metricsנתונים גולמיים לשדה בודד, בלי דירוגdataset.py
aggregate_records"% מ-X שהם Y" (חלק יחסי)analytics.py
estimate_derived_metric"ביקוש בלתי-ממומש ב-SFO, ולמה" (מודל + גורמים)analytics.py, runway_geometry.py
get_live_airport_statusסטטוס תפעולי חי (סגירות/עיכובים) — מחוץ למסלול המדורג במכווןnasstatus.faa.gov, זמן-אמת
list_criteria() נוצר כי הסוכן פעם סירב לומר את משקלי הדירוג שלו כשנשאל ישירות ("קנייני" — שום דבר בקוד לא אמר את זה; לא היה לו כלי לנתב אליו שאלת-מתודולוגיה טהורה). המשקלים תמיד היו אמיתיים וקבועים — עכשיו יש קריאת-כלי שהופכת אותם למתחקים בדיוק כמו כל מספר אחר.

app/conversation.py

חדש

היסטוריית צ'אט בזיכרון, משותפת לטקסט ולקול.

truncate_last_reply()
פעולת ההפרעה (barge-in): קוצצת תשובה שמורה לקידומת-המשפט שבאמת נאמרה
מעקב generation
כדי ש-/reset באמצע תור לא יבוטל בשקט

app/system_prompt.py

חוצה-שכבות

קבועי מחרוזת (לא מנוע תבניות) — בדיק ב-unit test שכלל קריטי קיים בטקסט שנשלח למודל.

NEVER_COMPUTE_RULE
אוסר על המודל לחשב או לנחש כל מספר
NEVER_INVENT_IDS_RULE
אוסר על המודל להמציא מזהה פריט
כלל 6
להציג תיקו כתיקו — לא להחליק ל"מנצח" כשהשניים הראשונים בטווח 0.005

app/guardrails.py

חוצה-שכבות

פילטר regex דטרמיניסטי — לא סיווג מבוסס LLM.

wrap_untrusted(text, source)
כל פלט כלי עובר דרך זה, בלי יוצא מן הכלל
למה לא LLM שני
קריאה נוספת תהיה איטית יותר, לא-דטרמיניסטית, ותקיפה בפני עצמה

app/config.py

קונפיגורציה

בורר *_PROVIDER (LLM_PROVIDER, STT_PROVIDER, TTS_PROVIDER), שמות מודלים, ו-_find_shared_env() שמטפס עד למצוא .env משותף.

ברירת מחדל
LLM_PROVIDER=mock — שיבוט והרצה חייבים לעבוד עם אפס הגדרה

9שכבת ה-Providers

הפשטה שמאפשרת להחליף ספק בשורה אחת, בלי לגעת בלולאה עצמה.

providers/llm/
base.py (Protocol: chat(messages, tools)), mock_llm.py (ברירת מחדל, אפס רשת), openai_llm.py (REST ישיר, לא ה-SDK — שטח ספק אחד), anthropic_llm.py, groq_llm.py (יורש מ-openai_llm.pyAPI תואם-חוט)
ברירת מחדל בפועל
OpenAI gpt-4o-mini — קטן, זול, מהיר, מודל tool-calling אמיתי (זו היכולת היחידה שהעיצוב נשען עליה)
נתיבי החלפה, שניהם אמיתיים
LLM_PROVIDER=anthropic (claude-haiku-4-5, ממומש מול צורת ה-Messages API המתועדת, לא נבדק חי בבנייה הזו) · LLM_PROVIDER=groq (llama-3.3-70b-versatile, נתיב חינם לבודק ללא מפתח בתשלום)
עלות מדידה
26 משימות eval: $0.026 על תורות קריאת-הכלים של הסוכן בלבד (עלות שיפוט ה-judge לא נספרת בסכום זה)
providers/stt/, providers/tts/
openai_stt.py (נקודת קצה תמלול REST) · openai_tts.py (ברירת מחדל) · google_tts.py (מימוש שני, מוכיח שהממשק אמיתי, לא one-off)
לא streaming
קריאות ה-LLM אינן זורמות ברמת הטוקן — הלולאה צריכה את רשימת tool_calls המלאה. ה-streaming חי ברמת יומן-קריאות-הכלים (/chat/stream, SSE) במקום

10שכבת הקול — שני נתיבים, בכוונה

הבריף קורא לצ'אט "דרישה" ולקול "בונוס". יש כאן שני נתיבי קול, וזה נבנה כפול בכוונה, לא מהיסוס.

נתיב 1: Browser-native, אפס אישורים

SpeechRecognition / speechSynthesis — הכתבה חד-פעמית, קריאת תשובות בקול. אין שרת מעורב, אין מפתח, אין עלות. "קול צריך מפתח שלא הגדרת" לא צריך להיות אותו משפט כמו "קול לא עובד בכלל". Chrome/Edge/Safari תומכים; Firefox לא — הכפתורים מכבים את עצמם עם tooltip, לא נכשלים בלחיצה.

נתיב 2: מצב שיחה אמיתי

מיקרופון פתוח, זיהוי-קצה בדפדפן, מודלי דיבור אמיתיים בשרת, תשובות מדוברות, והפרעה אמיתית (barge-in). זה הנתיב שהוא באמת "שיחה".

שלבאיפהמה
לכידה, דגימה ל-16kHzדפדפןstatic/voice.js
זיהוי-קצה (energy VAD)דפדפן200ms לפתיחה, 800ms שקט לסגירה, 300ms pre-roll
תמלולשרתPOST /voice/transcribegpt-4o-mini-transcribe
התור עצמושרתאותו /chat/stream — אותם כלים, אותם guardrails
סינתזהשרתPOST /voice/speakgpt-4o-mini-tts, קטע-אחר-קטע
הפרעהשניהםהדפדפן עוצר אודיו; POST /voice/interrupt מתקן את התמלול

למה הדפדפן מקשיב, לא השרת

שלוש סיבות: הפרעה נהיית מהירה יותר (המיקרופון והרמקול שניהם בדפדפן, עצירת נגינה עולה אפס-round-trip ברשת); שום דבר לא מועלה בזמן שקט; ו-frame energy בצד השרת דורש numpy בפרויקט עם רשימת תלויות בת ארבעה חבילות.

הפרעה היא שלושה שלבים, והשלישי הוא זה שחשוב

עצור נגינה. עצור סינתזה של מה שלא נוגן. אחר-כך שכתב את התשובה השמורה רק למשפטים שהמשתמש באמת שמע (app/conversation.py). בלי השלב השלישי, המודל מאמין שאמר חמישה משפטים שמעולם לא נשמעו, ושאלת המשך ("מה היה השלישי?") נענית מטקסט שאף אחד לא שמע. הקיצוץ ברמת-משפט, כי משפטים הם היחידה שמסונתזת ומתנגנת — קיצוץ ברמת-מילה היה דורש תזמון-פר-מילה ששני הספקים לא מחזירים מנקודת קצה פשוטה.

חור אמיתי שתוקן: /voice/interrupt אפשר לכתוב טקסט שרירותי לתוך התמלול השמור של הסוכן. עכשיו הוא דורש שהטקסט יהיה קידומת אמיתית של מה שבאמת נאמר. בנוסף, שני באגים קריטיים במחזור-החיים תוקנו: יציאה ממצב קול באמצע תמלול הזריקה תמלול ותוריד תור אמיתי ומחויב; יציאה במהלך בקשת הרשאת-מיקרופון השאירה מיקרופון פתוח יתום.

מה ה-VAD מבוסס-אנרגיה לא יכול לעשות

הוא לא יכול להבחין בין הפרעה אמיתית לבין תגובת-רקע — "מ-הממ" בזמן שהסוכן מדבר יעצור אותו. סף ההפרעה מכוון קשה יותר לחצות מסף פתיחת-תור (350ms ו-6dB+, לעומת 200ms) כי הפרעת-שווא חותכת תשובה באמצע ואיחור עולה רק רגע — אבל המגבלה עצמה היא תכונה של energy VAD, והתיקון הוא גלאי-תור סמנטי, לא סף טוב יותר. אקוסטית-הד (echoCancellation) היא תנאי-סף — בלעדיה קול הסוכן עצמו היה מפעיל את גלאי ההפרעה ברציפות.

ספק קול שני

שני ספקי הדיבור הם OpenAI — החלטת עלות-כניסה, לא העדפה: האפליקציה כבר צריכה מפתח OpenAI להריץ מול מודל אמיתי, אז דיבור-פנימה ודיבור-החוצה לא מוסיפים אף אישור. TTS_PROVIDER=google (Neural2) ממומש בכל זאת — ממשק עם מימוש יחיד מעולם לא נבדק כממשק.

11ה-Chat UI

עמוד סטטי יחיד, אפס build step — עם עיצוב מכוון לכיוון Wonderful.

app/main.py
FastAPI: GET /, GET /health, POST /chat, POST /chat/stream (SSE — זה שה-UI באמת משתמש בו), POST /reset, ומרכיב את נתיבי הקול
static/index.html
פאנל צ'אט + יומן קריאות-כלים חי + פקדי קול, אפס תלויות חדשות
static/markdown.js
מעבד markdown escape-firstescape קודם, פעם אחת, לפני כל טרנספורמציית markdown, כך שאין מסלול מטקסט-תשובה לתגי HTML חיים. href של קישורים מוגבל לסכימות מותרות (http/https/mailto) — תכונת אבטחה, לא סטיילינג
עיצוב
קנבס בהיר, פאנלי-קוד כהים, סגול שמור למצב-חי בלבד — שני גופנים (רגיל לפרוזה, mono לכל מספר וקריאת-כלי) הופכים את הכלל המרכזי של הפרומפט ("אתה מסביר ציון, אתה לא לעולם מחשב אחד") לבחירה עיצובית נראית
app/cli.py
ממשק שורת-פקודה חלופי לאותה לולאה בדיוק

12ה-Eval Harness — הבידול המרכזי

מנגנון הערכה אמיתי שרץ בפועל — לא מסמך תכנון. שתי הגשות ידועות אחרות שלחו רק תוכנית כתובה.

26משימות זרועות
24/26 = 92%openai (gpt-4o-mini)
0.97ציון ממוצע חלקי (openai)
16/26 = 62%mock — תחליף מתוסרט
90%הסכמת שופט מול תיוג אנושי

האנטומיה

מונחקובץתפקיד
Taskevals/types.pyקלט + הגריידר(ים) שבודקים אותו
Trialevals/types.pyניסיון בודד ומבודד
Traceevals/types.pyהתמלול המלא
Outcomeevals/types.pyהמצב הסופי הנגזר, זה שבפועל נבדק
Graderevals/graders/דטרמיניסטי או LLM-as-judge
Suiteevals/suite.pyרשימת משימות → SuiteResult

מבחן mutation מצא את הבאג הכי-יקר בכל הסשן

הריבוע נוסחת התרומה של scoring.py הזיז את הציון האמיתי של LAX ב-~0.05 והשאיר את כל 257 הטסטים ירוקים — כל טענה מספרית נפלה במקרה על נקודת-קיבוע של x²=x (0, 0.5, או 1.0), או בדקה רק סדר. תוקן עם טסט אריתמטי נגזר-ביד בערך שאינו אחת משלוש הנקודות האלה, פלוס נעיצה (pin) מול הנתונים האמיתיים.

שני ממצאים כנים שנשארו פתוחים, לא הונדסו החוצה

השוואת ריצות

--compare PRIOR_RUN.json משווה שתי ריצות פר-משימה, לא רק ביחס כולל — שני ריצות עם אותו אחוז-הצלחה יכולות להיות שתי הצלחות שונות לגמרי.

13טסטים — 296, אפס מפתחות

פירמידת בדיקות; רוב הבדיקות טהורות ומהירות.

אומת ב-git clone שני אמיתי, סביבה וירטואלית חדשה, אפס מפתחות: pytest ירוק, python -m app.cli רץ, השרת עולה, וארבע שאלות הבריף נענו מקצה-לקצה מול gpt-4o-mini אמיתי מאותו שיבוט.

14הרצה

מה צריך, ומה לא.

# clone + install
git clone https://github.com/roishik/airport-investment-intelligence-agent.git
cd airport-investment-intelligence-agent
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
cp .env.example .env   # then set OPENAI_API_KEY=sk-...

# run — real agent + real voice, same OPENAI_API_KEY
.venv/bin/uvicorn app.main:app --reload

# zero-setup path — mock provider, no key, no network
.venv/bin/pytest -q          # 296 passing
.venv/bin/python -m app.cli

# evals
.venv/bin/python -m evals.run_evals --provider openai

ברירת מחדל LLM_PROVIDER=mock — שיבוט והרצה חייבים לעבוד עם אפס הגדרה. LLM_PROVIDER=anthropic או =groq (מפתח חינמי) הם החלפות ישירות. TTS_PROVIDER=google + GCP_TTS_API_KEY מחליפים את ספק הקול — כל החלפת ספק היא משתנה סביבה אחד, אף פעם לא שינוי קוד.

15באגים שנמצאו ותוקנו — חומר הגנה מוכן

כל אלה נמצאו ע"י הרצת הסוכן והבדיקות בפועל, לא קריאת קוד — הסיבה שיש להם תיקון קונקרטי, לא רק תיאור.

שער הזכאות חי רק ב-compare_items

תוקן

שלושת כלי הדירוג האחים (rank_by_priorities, analyze_weight_sensitivity, weight_robustness_report) שיחזרו את כשל New Bedford Regional — שדה של אלפי נוסעים מדורג #4. עכשיו בכל ארבעת הכלים.

פרסר סטטוס-FAA החי שיטח את הפיד

תוקן

NOTAM שנתי של הגבלת GA ב-LAX נקרא כ"השדה סגור"; עיכוב אמיתי של 16-30 דקות ב-JFK איבד את המספרים שלו.

חור הפרעה (barge-in)

תוקן

/voice/interrupt אפשר טקסט שרירותי להיכתב לתמלול הסוכן עצמו. עכשיו דורש שהטקסט יהיה קידומת אמיתית של מה שנאמר.

שני באגי מחזור-חיים בקול

תוקן

יציאה ממצב קול באמצע תמלול הזריקה תמלול והריצה תור מחויב אמיתי; יציאה במהלך בקשת הרשאת-מיקרופון השאירה מיקרופון פתוח יתום.

באג רשימת-markdown

תוקן

הפיל בשקט פריטים מדורגים מתוך תשובה.

ריבוע נוסחת התרומה (mutation test)

תוקן

הזיז את הציון האמיתי של LAX ב-0.05, השאיר 257 טסטים ירוקים — כל טענה נפלה על נקודת-קיבוע של x²=x. תוקן עם טסט אריתמטי + נעיצה.

רצפת נרמול capacity_pressure תקועה

תוקן

201,438.53 קפוא במקום 287,264.425 האמיתי. שינה כל מספר בדוח: LAX 69→67, פער 0.41%→0.30%, הקריטריון הרגיש ביותר catchment_monopolytraffic_growth. נמצא ע"י טסט הנעיצה.

ערכי r נמדדו על 515, לא על 144 המדורגים

תוקן

הדוח לא ציין זאת. שתי האוכלוסיות מוצגות כעת בכל ארבעת הקריטריונים.

11 מתוך 18 commits נושאים Co-Authored-By: Claude

החלטה פתוחה, לא של Claude

ה-commit הראשוני מתאר את המאגר כמותאם מתבנית. תיקון דורש שכתוב היסטוריה + force-push למאגר פרטי שכבר נדחף — הרסני ולא הפיך, לכן לא בוצע ונשאר כהחלטה מתועדת ב-DECISIONS.md להחליט עליה לפני יום ההגנה.

16מה לא נבנה בכוונה

רשימת-קיצוץ כנה שווה יותר מרשימת-features ארוכה יותר.

17החלטות מרכזיות וטריידאופים

תמצית docs/DECISIONS_SUMMARY.md — לא תיאור ארכיטקטורה (זה בסעיפים למעלה), אלא סיפור ההחלטה: איזו בעיה נתקלה, מה נבחר, ומה המחיר. זה החומר שצריך לצאת בעל-פה כשנשאלים "למה ככה ולא אחרת".

1. לולאת הסוכן וארכיטקטורה

2. צנרת הנתונים

3. דירוג וקריטריונים — הרכיב המוגן ביותר

פורט במלואו, עם כל המספרים, בסעיף 6 למעלה — כאן רק תמצית ה"למה".

4. כלים וזיהוי ישויות

5. Guardrails ובטיחות

6. Eval Harness — הבידול המרכזי

מספרים מלאים בסעיף 12 למעלה.

7. UI וזרימה

8. קול — שני נתיבים בכוונה, לא היסוס

פורט במלואו בסעיף 10 למעלה.

9. בדיקה ואימות

10. מה לא נבנה בכוונה

רשימת-הקיצוץ המלאה, עם הנימוקים, בסעיף 16 למעלה — קיצוצי-היקף מפורשים, לא השמטות שנתגלו.

18מפת קבצים

רפרנס מהיר — כל תיקייה/קובץ, במשפט אחד.

app/ — הסוכן
נתיבתיאור
agent_loop.pyלולאת ה-agent הכתובה-ביד (~70 שורות)
tools.pyשטח 11 הכלים (1675 שורות, הקובץ הגדול ביותר)
scoring.pyדירוג משוקלל, דטרמיניסטי
entity_resolution.pyטקסט חופשי → מזהי שדות
analytics.pyסינון, אגרגציה, מדד נגזר
runway_geometry.pyהפרדת מסלולים, פגיעת קיבולת
dataset.pyהמקום היחיד שקורא מ-data/
system_prompt.pyכללי-הברזל שנשלחים למודל
guardrails.pyעטיפת נתון לא-מהימן + סריקת הזרקה
conversation.pyהיסטוריית שיחה + קיצוץ הפרעה
config.pyטעינת סביבה + בוררי ספק
main.pyשרת FastAPI + SSE
voice_api.pyנתיבי קול בצד-שרת
cli.pyממשק שורת-פקודה
providers/llm/, providers/stt/, providers/tts/הפשטות ספק
data/ — הנתונים
נתיבתיאור
raw_data/בדיוק מה שנשלף מכל מקור, לא נגוע
processed_data/candidates.jsonהטבלה הבנויה שהסוכן טוען, כולל בלוק _meta
refresh_data.pyצנרת שליפה-ובנייה-מחדש, אידמפוטנטית וללא מפתח
evals/ — מנגנון ההערכה
נתיבתיאור
types.pyאנטומיית Task/Trial/Trace/Outcome/Grader/Suite
runner.py, suite.py, report.pyהרצה, מדידה, פלט markdown/JSON
graders/deterministic.py, graders/llm_judge.pyשני סוגי הגריידר
judge_validation.py, judge_calibration_data.pyאימות השופט מול תיוג אנושי (90%)
tasks/seed_tasks.py26 המשימות הזרועות
results/ריצות אמיתיות מחויבות — mock ו-openai
static/, tests/, scripts/
נתיבתיאור
static/index.htmlעמוד ה-Chat + Voice UI
static/markdown.jsמעבד markdown escape-first
static/voice.jsלכידת מיקרופון, זיהוי-קצה, ניגון, הפרעה
tests/test_*.py296 טסטים, לפי מודול
scripts/run_example_questions.pyמריץ את ארבע שאלות הבריף מקצה-לקצה
scripts/smoke_test.pyבדיקת עשן מול API אמיתי
מסמכי-על (בשורש) ו-docs/
נתיבתיאור
README.mdהרצה מהירה, מבנה
DESIGN_DOC.mdמתודולוגיית דירוג, איפה נעשה שימוש ב-AI, trade-offs — מסמך העיצוב הנדרש
DECISIONS.mdיומן החלטות מלא, לפי סדר בנייה
ASSUMPTIONS.mdכל פער נתונים, המרת יחידה, תאריך-התיישנות
evaluation_plan.mdמטריצת הבדיקה ותוצאות ריצה אמיתיות
docs/ARCHITECTURE.mdמפת קבצים + זרימת בקשה
docs/DECISIONS_SUMMARY.mdאותן החלטות, מאורגנות לפי נושא