ארכיטקטורת ה-Assignment Scaffold

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

Python Hand-rolled agent loop Zero-I/O deterministic core Eval harness FastAPI

1מבוא

מה זה ה-scaffold הזה, ולמי הוא מיועד.

זהו שלד (scaffold) לפרויקט Python לבניית agent מבוסס tool-calling, שנועד להיות משוכפל (cloned) ומותאם מחדש עבור כל משימה חדשה — בלי לבנות תשתית מאפס בכל פעם. הרעיון המרכזי: להפריד באופן חד וברור בין שני סוגי לוגיקה שנוטים להתערבב בפרויקטים כאלה —

התוצאה היא agent שיודע להסביר כל מספר שהוא אומר, כי כל מספר הגיע מפונקציה דטרמיניסטית שניתן להצביע עליה ולהריץ עליה בדיקה.

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

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

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

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

scoring.py, entity_resolution.py ו-analytics.py לא עושים אף קריאת רשת, קריאת קובץ, או קריאת משתנה סביבה. כל פונקציה שם היא פונקציה טהורה — אותו קלט תמיד מייצר אותו פלט.

Hand-rolled agent loop, בלי framework

אין LangChain, אין LangGraph. לולאת ה-agent היא while פשוט על קריאה ל-LLM provider אחיד, כדי לשמור על שליטה מלאה בזרימה ובניפוי שגיאות.

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

כל חריגה שנזרקת בתוך כלי (tool) נתפסת ומומרת ל-{"error": ...} שחוזר למודל כטקסט — הלולאה עצמה אף פעם לא קורסת בגלל כלי שנכשל.

פער ידוע, לא מוסתר: ה-try/except הזה עוטף רק את קריאת הכלי (agent_loop.py שורה 109). קריאת ה-provider.chat() עצמה (שורה 82) לא עטופה — כשל של ה-provider עצמו (timeout, rate limit, מפתח שגוי) עדיין יעלה כחריגה בלתי-מטופלת עד ל-FastAPI, ולא יומר לתשובה מנוסחת. תשובה כנה מוכנה מראש עדיפה על הפתעה בזמן הגנה.

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

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

לעולם לא להמציא מזהה

NEVER_INVENT_IDS_RULE אוסר על המודל לנחש מזהה פריט. אם המשתמש מתאר פריט במילים, יש לקרוא קודם לכלי חיפוש (resolve_entity) ולא "לזכור" מזהה.

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

כל תוצאה שמגיעה מכלי נעטפת (wrap_untrusted) לפני שהיא חוזרת לשיחה, כדי שהמודל לעולם לא יתייחס לתוכן חיצוני כאל הוראה.

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

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

ממשק משתמש (Chat UI) static/index.html · app/cli.py
שכבת ה-API app/main.py (FastAPI)
Agent Loop app/agent_loop.py
שכבת ה-Tools app/tools.py — עוטף את הליבה הדטרמיניסטית עבור המודל
ליבה דטרמיניסטית (אפס I/O) scoring.py · entity_resolution.py · analytics.py
חוצה-שכבות: Guardrails ו-System Prompt guardrails.py · system_prompt.py
שכבת ה-Providers providers/llm/ · providers/stt/ · providers/tts/
קונפיגורציה ותשתית config.py · .env

שני עולמות עצמאיים נוספים חיים לצד הליבה הזו: שכבת הקול האופציונלית (app/voice/) ו-מנגנון ההערכה (evals/) — שניהם מפורטים בהמשך המסמך.

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

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

1. הודעת משתמש נשלחת
POST /chat ב-app/main.py, או קלט מ-app/cli.py
2. run_agent() ב-agent_loop.py
בונה רשימת הודעות (system + היסטוריה + הודעה חדשה) ומעביר ל-provider
3. LLM Provider.chat()
מחזיר תשובה סופית או בקשת tool call(s)
4א. אין tool calls
הטקסט הסופי מוחזר למשתמש — סוף הלולאה
4ב. יש tool calls
כל קריאה מנותבת דרך TOOL_REGISTRY ל-app/tools.py
5. הכלי קורא לליבה הדטרמיניסטית
scoring.py / entity_resolution.py / analytics.py — מחזיר תוצאה מובנית (JSON-ready), לעולם לא מספר בודד
6. תפיסת שגיאות
אם הכלי זרק חריגה — נתפסת ב-agent_loop.py, נרשמת ב-tool_log, לא קורסת את הלולאה
7. עטיפת "נתון לא מהימן"
guardrails.wrap_untrusted() עוטף את תוצאת הכלי לפני שהיא חוזרת לשיחה, וסורק אותה (scan_for_injection) לתבניות prompt-injection נפוצות
8. חזרה לצעד 2
התוצאה נוספת להיסטוריה, והלולאה קוראת שוב ל-provider — עד תשובה סופית או עד max_turns
9. תשובה + tool_log חוזרים ללקוח
app/main.py מחזיר JSON עם הטקסט הסופי ורשימת הקריאות לכלים — זה מה שממשק ה-Chat UI מציג בזמן אמת
הגנה מובנית: max_turns (בררת מחדל: 6) עוצר את הלולאה בכוח וזורק MaxTurnsExceeded אם ה-provider ממשיך לבקש כלים בלי סוף — ערבות סיום שהלולאה חייבת לאכוף בעצמה, כי שום דבר חיצוני לא עושה זאת.

5רכיבי הליבה

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

app/agent_loop.py

לולאת ה-Agent

הלולאה עצמה — while פשוט, בלי framework, שמנהל את השיחה עם ה-LLM ואת קריאות הכלים.

טיפוסים מרכזיים
ToolLogEntry, AgentResult, MaxTurnsExceeded
פונקציה מרכזית
run_agent(user_message, history, provider, tool_schemas, tool_registry, system_prompt, max_turns=6)
מנגנון
שולח הודעות ל-provider → אם יש tool_calls, מריץ אותם, עוטף תוצאה כ"נתון לא מהימן", מוסיף להיסטוריה, לולאה שוב → אם אין tool_calls, זו התשובה הסופית
עיקרון
כל קריאה וכל תוצאה נרשמות ב-tool_log לפי סדר — זה מה שמאפשר תצוגת "reasoning trace" חיה בממשק

app/scoring.py

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

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

Criterion
מימד דירוג בודד — שם, משקל יחסי, טווח נורמליזציה (lower_bound/upper_bound), כיוון (higher_is_better)
score_item()
מחשב ציון משוקלל לפריט בודד. מטפל בנתונים חסרים: מוריד קריטריונים חסרים ומנרמל מחדש את שאר המשקלים — אם הכיסוי לא עובר סף (coverage_threshold, בררת מחדל 0.5, לא כולל את הסף עצמו — כיסוי חייב לעבור את הסף, לא רק להשוות לו), נזרקת InsufficientCoverageError ייעודית
rank_items()
מדרג קבוצת פריטים; מחזיר RankingResult עם שתי רשימות נפרדות — ranked ו-excluded — כדי שפריט שנפסל לא ייעלם בשקט
ניתוח רגישות
sensitivity_analysis(), find_weight_flip_point(), apply_priority_emphasis() — מאפשרים לבדוק "מה קורה אם משנים משקל?" בצורה כמותית, ולתת למשתמש לבטא העדפות מבלי לתת למודל לקבוע ערכים באופן חופשי

app/entity_resolution.py

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

התאמת טקסט חופשי (שם, כינוי, איות משוער) למזהה פריט קונקרטי — כדי שהמודל לעולם לא יצטרך "לזכור" מזהה.

שיטות התאמה
Jaro-Winkler (מרחק מחרוזות, מוטה לתחילית משותפת) + Soundex (התאמה פונטית) — שתיהן ממומשות ידנית, ללא תלות חיצונית
resolve()
מחזיר רשימת מועמדים עם ציון ביטחון (confidence) ודגל decisive
קריטריון ל-decisive
נדרשים שני תנאים יחד: ביטחון מעל סף מוחלט, וגם פער מהמועמד השני — כי ביטחון גבוה עם מועמד קרוב עדיין אומר "לא חד-משמעי"
כיול
scripts/calibrate_resolver.py — קובע את הספים לפי מדידה על זוגות מתויגים, לא לפי תחושת בטן

app/analytics.py

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

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

filter_items()
סינון פריטים לפי תכונות (סמנטיקת AND, לא OR). מפתח סינון לא-מוכר לא מתעלם ממנו — הוא מחזיר "אין התאמות" ומדווח אילו שדות באמת קיימים
aggregate()
סטטיסטיקה (חלק יחסי / ממוצע / ספירה / סכום) על תת-רשומות של ישות בודדת — לא דירוג בין ישויות
build_derived_metric()
ה-חוזה הגנרי למדד מחושב (derived metric): הערך מחושב מתוך גורמים תורמים (factors) שסכומם שווה לערך עצמו, ולא מתקבל בנפרד — כך הסבר לא יכול לסטות מהמספר שהוא אמור להסביר. התוצאה חייבת לשאת גם הנחות-יסוד וגם רמת ביטחון — לא ניתן לבנות תוצאה בלעדיהן
הנוסחה עצמה של מדד מחושב ספציפי היא קוד דומיין ושייכת ל-tools.py, לא לקובץ הזה — בדיוק כמו ש-scoring.py הוא המנוע ו-DEFAULT_CRITERIA ב- tools.py הם הדומיין.

app/tools.py

שכבת חיבור

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

TOOL_SCHEMAS
רשימת הגדרות כלים בפורמט OpenAI-style function calling — שם, תיאור (כולל הנחיה מפורשת "אל תנחש"), וסכמת פרמטרים
TOOL_REGISTRY
מילון פשוט: שם כלי → פונקציה. זו כל "טבלת הניתוב" שלולאה כתובה-ביד צריכה — בלי מנגנון רישום מורכב
עקרון מרכזי
כל כלי מחזיר פירוק לרכיבים (ערך גולמי, ציון מנורמל, משקל, תרומה) — לעולם לא מספר בודד — כדי שהמודל יסביר תוצאה שהוא לא חישב

תשעת הכלים החשופים למודל

כליתפקיד גנרי
compare_itemsדירוג/השוואה בין כמה פריטים לפי קריטריונים
get_item_metricsנתונים גולמיים לפריט בודד, בלי דירוג
resolve_entityהתאמת טקסט חופשי למזהי פריטים, עם ציון ביטחון
find_itemsסינון פריטים לפי תכונות (סמנטיקת AND)
aggregate_recordsסטטיסטיקה על תת-רשומות של ישות בודדת
estimate_derived_metricמדד מחושב, עם גורמים תורמים והנחות-יסוד
rank_by_prioritiesדירוג עם משקלים מותאמים להעדפות שהמשתמש ציין במילים
analyze_weight_sensitivityמה קורה לדירוג אם משקל אחד משתנה
weight_robustness_reportלכל קריטריון — כמה צריך לשנות את המשקל כדי להפוך את הזוכה
מה מוחלף בכל שימוש חדש
נתוני ה-mock, הקריטריונים הספציפיים, נוסחת המדד המחושב — הכל בקובץ הזה. שאר השכבות נשארות ללא שינוי

app/system_prompt.py

חוצה-שכבות

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

NEVER_COMPUTE_RULE
אוסר על המודל לחשב, להעריך או לנחש כל מספר — כל ציון חייב לבוא מקריאה לכלי
NEVER_INVENT_IDS_RULE
אוסר על המודל להמציא או "לזכור" מזהה פריט — יש לקרוא ל-resolve_entity ולהתייחס במפורש למקרה decisive=false
כלל נוסף
לציין הנחות-יסוד וחוסר-ודאות במפורש כשהם משפיעים על התשובה, ולא "לטייח" פער בנתונים

app/guardrails.py

חוצה-שכבות

הגנה על השיחה מפני prompt injection שמגיע דרך פלט של כלי (למשל טקסט חופשי שנשלף ממקור חיצוני) — לא סיווג מבוסס LLM, אלא מסנן דטרמיניסטי.

wrap_untrusted(text, source)
עוטף כל פלט כלי בתגית <untrusted_data> מפורשת — כל פלט כלי עובר דרך זה, בלי יוצא מן הכלל
scan_for_injection(text)
סורק ביטויים נפוצים ("ignore previous instructions", תגיות תפקיד מזויפות וכו') באמצעות ביטויים רגולריים — זול, מהיר, דטרמיניסטי
למה לא LLM שני לסיווג
קריאה נוספת ל-LLM תהיה איטית יותר, לא-דטרמיניסטית, ותקיפה בפני עצמה

app/config.py

קונפיגורציה

כל ערך מגיע ממשתנה סביבה עם בררת מחדל סבירה. שום דבר מחוץ לקובץ הזה לא ניגש ישירות ל-API key או ל-.env.

בררת מחדל
LLM_PROVIDER=mock — שיבוט הפרויקט והרצתו חייבים לעבוד עם אפס הגדרה, בלי מפתח ובלי רשת
איתור .env משותף
_find_shared_env() מטפס עד 5 רמות תיקיות למעלה כדי למצוא קובץ .env משותף — לא מניח עומק קבוע, ורושם log באיזה קובץ נעשה שימוש (בלי לחשוף ערכים)

6שכבת ה-Providers

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

providers/llm/base.py מגדיר Protocol אחיד — LLMResponse, ToolCall, ומתודת chat(messages, tools). כל מימוש קונקרטי (mock_llm.py, openai_llm.py, anthropic_llm.py) מיישם את אותה צורה בדיוק, כך שה-agent loop ו-app/main.py לעולם לא יודעים איזה ספק פעיל.

בורר ספק
providers/llm/__init__.py:get_llm_provider() — קורא את LLM_PROVIDER מ-config.py, ובונה את המימוש המתאים (עם singleton פנימי)
Mock provider
מאפשר להריץ את כל השלד — כולל טסטים — בלי אף מפתח API. חיוני להרצה מיד לאחר שיבוט
לא streaming
קריאות ה-LLM בשכבה הזו אינן זורמות (non-streaming) — הלולאה צריכה את רשימת ה-tool_calls המלאה לפני שהיא יכולה להחליט מה לעשות הלאה

7שכבת הקול האופציונלית

app/voice/ + app/providers/{stt,tts}/ — קיימת, אבל אף פעם לא חובה.

פרויקט טקסטואלי חייב להיות מסוגל לרוץ בלי אף תלות של אודיו. לכן שום דבר בנתיב הליבה (agent loop, scoring, tools, evals) לא מייבא את החבילה הזו. הבדיקה נעשית בגבול המפורש: app/voice/__init__.py:require_voice_dependencies() זורק VoiceLayerUnavailable עם הודעה שמפרטת בדיוק מה חסר ואיך להתקין (requirements-voice.txt) — במקום ModuleNotFoundError גנרי כמה שכבות עמוק בתוך provider.

שים לב: app/voice/llm/ הוא ממשק LLM נפרד ומכוון, לא כפילות. שכבת ה-agent הראשית משתמשת ב-chat(messages, tools) סינכרוני עם tool-calling; שכבת הקול משתמשת ב-await complete(system, user) אסינכרוני שעוקב אחרי זמן-לטוקן-ראשון (ttft_ms) לתקציב ה-latency. שני חוזים שונים לגמרי — לכן לא אוחדו, אלא הופרדו תחת מרחבי שם נפרדים.

8ה-Chat UI

מכוון להיות קטן ככל האפשר — בכוונה.

app/main.py
FastAPI עם ארבעה נתיבים בלבד: GET / (הדף), GET /health (בדיקת חיות — מחזיר גם איזה provider ומודל פעילים), POST /chat, POST /reset. היסטוריית שיחה נשמרת בזיכרון — מספיק להדגמת יחיד, לא לייצור מרובה-משתמשים
static/index.html
עמוד HTML יחיד, ללא build tooling — מציג את התשובה וגם את tool_log חי, כדי שרואים בדיוק אילו כלים נקראו ובאיזה סדר
app/cli.py
ממשק שורת-פקודה חלופי לאותה לולאה בדיוק — שימושי לבדיקה מהירה בלי להריץ שרת

ההחלטה הזו נובעת ממחקר על משימות-בית של 24 שעות: ליטוש ממשק הוא בדיוק המקום שבו זמן נשפך, והוא לא מה שנבדק.

9ה-Eval Harness

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

האנטומיה

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

כלל: מעריכים תוצאה, לא נתיב

ברירת המחדל היא לבדוק את התוצאה הסופית — לא באיזה סדר הכלים נקראו. חריג אחד מוצהר בלבד: Task.expected_tool / Task.forbidden_tools, המיועד למקרים שבהם בחירת הכלי עצמה היא מה שנבדק.

שני סוגי Grader

דטרמיניסטי

evals/graders/deterministic.py — בדיקות קוד טהורות: האם המספרים תואמים חישוב עצמאי, האם כל מספר שנאמר ניתן לאיתור בפלט כלי אמיתי, האם נקרא הכלי הנכון.

LLM-as-judge

evals/graders/llm_judge.py — לשיפוט איכות פתוחה (הסבר בהיר, טיפול בעמימות, סירוב הזרקה). לא נחשב אמין עד שנבדק מול תיוג אנושי — evals/judge_validation.py מריץ בדיוק את הבדיקה הזו ומדווח אחוז הסכמה בפועל.

מדדי עלות וזמן

כל Trial נמדד בנפרד: זמן ריצה של ה-agent (latency_seconds, בלי זמן השיפוט), טוקנים ועלות (cost_usdNone כשלא נמדד, לעולם לא 0.0, כדי שלא ייקרא "בחינם"). SuiteResult חושף גם p50 וגם p95 — כי התפלגות זמן-ריצה של לולאת agent ארוכת-זנב מטבעה.

השוואת ריצות

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

10טסטים

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

11קונפיגורציה והרצה

מה צריך כדי להריץ, ומה לא.

requirements.txt
ליבה בלבד — FastAPI, uvicorn, httpx, pydantic. שום תלות אודיו
requirements-voice.txt
תוספות לשכבת הקול בלבד (-r requirements.txt + numpy, jsonschema וכו')
requirements-dev.txt
כלי פיתוח — pytest, pytest-asyncio
.env
מפתחות API — נטען דרך config.py בלבד, ולעולם לא נרשם ל-log או מודפס
# basic run — zero config, mock provider
pip install -r requirements.txt
python -m app.cli

# with the voice layer
pip install -r requirements.txt -r requirements-voice.txt

# tests
pip install -r requirements-dev.txt
PYTHONPATH=. pytest -q

12מפת קבצים

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

app/ — הליבה
נתיבתיאור
agent_loop.pyלולאת ה-agent הכתובה-ביד
scoring.pyדירוג משוקלל, דטרמיניסטי, גנרי
entity_resolution.pyהתאמת טקסט חופשי למזהי פריטים
analytics.pyסינון, סטטיסטיקה, חוזה למדד מחושב
tools.pyהגדרות כלים + נתוני דוגמה (מוחלף בכל שימוש)
system_prompt.pyכללי-הברזל שנשלחים למודל
guardrails.pyעטיפת נתון לא-מהימן + סריקת הזרקה
config.pyטעינת משתני סביבה, כולל אודיו
main.pyשרת FastAPI
cli.pyממשק שורת-פקודה
providers/llm/הפשטת ספק LLM (mock/openai/anthropic)
providers/stt/, providers/tts/ספקי דיבור-לטקסט וטקסט-לדיבור (שכבת קול)
voice/שכבת קול אופציונלית — STT→LLM→TTS
evals/ — מנגנון ההערכה
נתיבתיאור
types.pyאנטומיית Task/Trial/Trace/Outcome/Grader/Suite
runner.pyמריץ Trial בודד, ממודד, שומר בידוד
suite.pySuite + SuiteResult
report.pyפלט Markdown/JSON, כולל השוואת ריצות (--compare)
graders/deterministic.pyגרייד קוד — בלי LLM
graders/llm_judge.pyגרייד LLM-as-judge + רובריקות
judge_validation.pyאימות השופט מול תיוג אנושי
judge_calibration_data.pyדוגמאות מתויגות ידנית
tasks/seed_tasks.pyמשימות ההערכה הזרועות
run_evals.pyנקודת כניסה מ-CLI
tests/, scripts/, static/
נתיבתיאור
tests/test_*.pyבדיקות יחידה ואינטגרציה, לפי מודול
scripts/smoke_test.pyבדיקת עשן מול API אמיתי
scripts/calibrate_resolver.pyכיול ספי entity_resolution.py
static/index.htmlעמוד ה-Chat UI
מסמכי-על
נתיבתיאור
README.mdהסבר מפורט לכל רכיב, כולל נימוקי עיצוב
DESIGN_DOC.mdמסמך עיצוב — תבנית למילוי לכל שימוש חדש
DECISIONS.mdיומן החלטות — שורה אחת לכל בחירה לא-טריוויאלית
ASSUMPTIONS.mdהנחות-יסוד ותחום התאמה
evaluation_plan.mdתוכנית ההערכה