1מבוא
מה זה ה-scaffold הזה, ולמי הוא מיועד.
זהו שלד (scaffold) לפרויקט Python לבניית agent מבוסס tool-calling, שנועד להיות משוכפל (cloned) ומותאם מחדש עבור כל משימה חדשה — בלי לבנות תשתית מאפס בכל פעם. הרעיון המרכזי: להפריד באופן חד וברור בין שני סוגי לוגיקה שנוטים להתערבב בפרויקטים כאלה —
- לוגיקה דטרמיניסטית (חישוב ציונים, דירוג, חיפוש, סטטיסטיקה) — קוד Python רגיל, ניתן לבדיקה מלאה (unit tests) בלי אף קריאת רשת ובלי אף קריאה ל-LLM.
- לוגיקה מבוססת שפה (הבנת בקשה, ניסוח תשובה, החלטה איזה כלי לקרוא) — נתונה ל-LLM, אבל תמיד דרך tools שמחזירים נתונים מוכנים, ולעולם לא דרך חישוב "בזיכרון" של המודל.
התוצאה היא 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ארכיטקטורה כללית — שכבות
מבט-על מלמעלה למטה: מהממשק למשתמש ועד לתשתית.
static/index.html · app/cli.py
app/main.py (FastAPI)
app/agent_loop.py
app/tools.py — עוטף את הליבה הדטרמיניסטית עבור המודל
scoring.py · entity_resolution.py · analytics.py
guardrails.py · system_prompt.py
providers/llm/ · providers/stt/ · providers/tts/
config.py · .env
שני עולמות עצמאיים נוספים חיים לצד הליבה הזו: שכבת הקול האופציונלית
(app/voice/) ו-מנגנון ההערכה (evals/) — שניהם מפורטים
בהמשך המסמך.
4זרימת בקשה טיפוסית
מה קורה בפועל, צעד אחר צעד, כאשר משתמש שולח הודעה.
POST /chat ב-app/main.py, או קלט מ-app/cli.pyrun_agent() ב-agent_loop.pysystem + היסטוריה + הודעה חדשה) ומעביר ל-providerchat()TOOL_REGISTRY ל-app/tools.pyscoring.py / entity_resolution.py / analytics.py —
מחזיר תוצאה מובנית (JSON-ready), לעולם לא מספר בודדagent_loop.py, נרשמת ב-tool_log,
לא קורסת את הלולאהguardrails.wrap_untrusted() עוטף את תוצאת הכלי לפני שהיא חוזרת לשיחה,
וסורק אותה (scan_for_injection) לתבניות prompt-injection נפוצותmax_turnstool_log חוזרים ללקוחapp/main.py מחזיר JSON עם הטקסט הסופי ורשימת
הקריאות לכלים — זה מה שממשק ה-Chat UI מציג בזמן אמתmax_turns (בררת מחדל: 6) עוצר את הלולאה בכוח וזורק
MaxTurnsExceeded אם ה-provider ממשיך לבקש כלים בלי סוף — ערבות
סיום שהלולאה חייבת לאכוף בעצמה, כי שום דבר חיצוני לא עושה זאת.
5רכיבי הליבה
צלילה לעומק: קובץ אחר קובץ, מה כל אחד עושה ואיזה עיקרון הוא אוכף.
app/agent_loop.py
לולאת ה-Agentהלולאה עצמה — while פשוט, בלי framework, שמנהל את השיחה עם ה-LLM ואת קריאות הכלים.
ToolLogEntry, AgentResult,
MaxTurnsExceededrun_agent(user_message, history, provider,
tool_schemas, tool_registry, system_prompt, max_turns=6)tool_calls, מריץ אותם, עוטף תוצאה כ"נתון לא מהימן", מוסיף להיסטוריה, לולאה
שוב → אם אין tool_calls, זו התשובה הסופיתtool_log לפי סדר — זה
מה שמאפשר תצוגת "reasoning trace" חיה בממשקapp/scoring.py
ליבה דטרמיניסטיתמנוע דירוג משוקלל, גנרי לחלוטין — לא תלוי בשום דומיין. הקובץ הכי "ניתן להגנה" בכל ה-scaffold: זו התשובה הישירה ל"תראה לי את הלוגיקה הדטרמיניסטית".
Criterionlower_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
ליבה דטרמיניסטיתהתאמת טקסט חופשי (שם, כינוי, איות משוער) למזהה פריט קונקרטי — כדי שהמודל לעולם לא יצטרך "לזכור" מזהה.
resolve()confidence)
ודגל decisivedecisivescripts/calibrate_resolver.py — קובע את הספים לפי
מדידה על זוגות מתויגים, לא לפי תחושת בטןapp/analytics.py
ליבה דטרמיניסטיתכל צורת שאלה שהיא לא דירוג. גנרי לגמרי — שום פרט ספציפי לדומיין לא נכנס לקובץ הזה.
filter_items()aggregate()build_derived_metric()factors)
שסכומם שווה לערך עצמו, ולא מתקבל בנפרד — כך הסבר לא יכול לסטות מהמספר שהוא אמור להסביר.
התוצאה חייבת לשאת גם הנחות-יסוד וגם רמת ביטחון — לא ניתן לבנות תוצאה בלעדיהןtools.py, לא
לקובץ הזה — בדיוק כמו ש-scoring.py הוא המנוע ו-DEFAULT_CRITERIA ב-
tools.py הם הדומיין.app/tools.py
שכבת חיבורהמקום היחיד שמחבר בין הליבה הדטרמיניסטית לבין מה שהמודל רואה. כאן חיים גם נתוני הדוגמה וגם ה-הגדרה של כל כלי.
TOOL_SCHEMASTOOL_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 | לכל קריטריון — כמה צריך לשנות את המשקל כדי להפוך את הזוכה |
app/system_prompt.py
חוצה-שכבותקבועי מחרוזת (לא מנוע תבניות) — כדי שקל לבדוק ב-unit test שכלל קריטי באמת קיים בטקסט שנשלח למודל.
NEVER_COMPUTE_RULENEVER_INVENT_IDS_RULEresolve_entity ולהתייחס במפורש למקרה
decisive=falseapp/guardrails.py
חוצה-שכבותהגנה על השיחה מפני prompt injection שמגיע דרך פלט של כלי (למשל טקסט חופשי שנשלף ממקור חיצוני) — לא סיווג מבוסס LLM, אלא מסנן דטרמיניסטי.
wrap_untrusted(text, source)<untrusted_data> מפורשת — כל פלט כלי עובר דרך זה, בלי יוצא מן הכללscan_for_injection(text)app/config.py
קונפיגורציהכל ערך מגיע ממשתנה סביבה עם בררת מחדל סבירה. שום דבר מחוץ לקובץ הזה לא ניגש ישירות ל-API key או ל-.env.
LLM_PROVIDER=mock — שיבוט הפרויקט והרצתו חייבים
לעבוד עם אפס הגדרה, בלי מפתח ובלי רשת_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 פנימי)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.pyGET / (הדף), GET /health (בדיקת חיות — מחזיר גם איזה
provider ומודל פעילים), POST /chat, POST /reset.
היסטוריית שיחה נשמרת בזיכרון — מספיק להדגמת יחיד, לא לייצור מרובה-משתמשיםstatic/index.htmltool_log חי, כדי שרואים
בדיוק אילו כלים נקראו ובאיזה סדרapp/cli.pyההחלטה הזו נובעת ממחקר על משימות-בית של 24 שעות: ליטוש ממשק הוא בדיוק המקום שבו זמן נשפך, והוא לא מה שנבדק.
9ה-Eval Harness
מנגנון הערכה אמיתי שרץ בפועל — לא מסמך תכנון בלבד.
האנטומיה
| מונח | קובץ | תפקיד |
|---|---|---|
Task | evals/types.py | קלט + הגרייד(ים) שבודקים אותו |
Trial | evals/types.py | ניסיון בודד ומבודד להרצת
Task |
Trace | evals/types.py | התמלול המלא — קריאות כלים, הודעות, תשובה סופית |
Outcome | evals/types.py | המצב הסופי הנגזר, שהגרייד
בפועל בודק (לא ה-Trace עצמו) |
Grader | evals/graders/ | דטרמיניסטי או LLM-as-judge |
Suite | evals/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_usd — None כשלא נמדד, לעולם לא
0.0, כדי שלא ייקרא "בחינם"). SuiteResult חושף גם p50
וגם p95 — כי התפלגות זמן-ריצה של לולאת agent ארוכת-זנב מטבעה.
השוואת ריצות
--compare PRIOR_RUN.json משווה שתי ריצות פר-משימה, לא רק ביחס כולל
— כי שני ריצות עם אותו אחוז-הצלחה כולל יכולות להיות שתי הצלחות שונות לגמרי: אחת שתיקנה שתי
משימות ושברה שתיים אחרות תיראה זהה במבט-על, ותיחשף רק בהשוואה פר-משימה.
10טסטים
פירמידת בדיקות — רוב הבדיקות טהורות ומהירות.
- יחידה טהורה (
test_scoring.py,test_analytics.py,test_entity_resolution.py,test_tools_domain.py) — בודקים את הליבה הדטרמיניסטית ישירות, בלי agent, בלי LLM, בלי רשת. - אינטגרציה עם mock (
test_agent_loop.py) — מריץ את הלולאה המלאה מול provider מדומה, בודק תפיסת שגיאות, עצירה ב-max_turns, ושהכללים ב-system promptאכן קיימים בטקסט הנשלח. - Guardrails וקונפיגורציה
(
test_guardrails.py,test_config.py) — בודקים את עטיפת הנתון הלא-מהימן, סריקת ההזרקה, וטעינת ה-.env המשותף (כולל התרחיש של קינון ברמות שונות). - Grader (
test_graders.py) — בודקים את מנגנון ההערכה עצמו, כי גרייד שלא נבדק הוא מכשיר מדידה לא-מאומת. - שכבת קול (
test_voice_barge_in.py,test_voice_grader.py,test_voice_scheduler.py) — רצים רק אם תלויות האודיו מותקנות; ראו סעיף שכבת הקול.
11קונפיגורציה והרצה
מה צריך כדי להריץ, ומה לא.
requirements.txtrequirements-voice.txt-r requirements.txt + numpy, jsonschema
וכו')requirements-dev.txt.envconfig.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.py | Suite + 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 | תוכנית ההערכה |