Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
01-02b · Structured outputs and prompt evaluation
series
Blocks 0–10 · Block 2 — Prompt Engineering & Evals · Part 2/2
level
Intermediate
duration
3–4 h
prerequisites
01-02a (prompt engineering) · Python 3.10+ · a local model server (Ollama or vLLM)
trust_label
VERIFIED 2026-10-01 (library versions and APIs checked against PyPI packages and official docs; Pydantic schema executed locally) · UPDATED 2026-10-01 · LLM calls NOT run end to end
language
human view: bg · english edition: /en/academy/blokove/moduli/01-02b_Блок_2_Част_2_Eval_Structured_Outputs.html
next
01-03a_Блок_3_Част_1_Агенти_LangGraph.html · Block 3: AI agents and orchestration
PURPOSE

Turn free-text LLM output into validated, typed data (JSON schema, Pydantic, Instructor) and turn prompt changes into measured decisions (fixed test set, A/B comparison, LLM-as-a-judge, RAGAS / deepeval metrics, regression gate in CI).

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

01-03a_Блок_3_Част_1_Агенти_LangGraph.html · Block 3: AI agents and orchestration (LangGraph, CrewAI, n8n) · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
structured-outputsjson-schemapydanticinstructorollamavllmevaluationllm-as-judgeragasdeepevalregression-testing
ПРОВЕРЕНО · 01.10.2026 ОБНОВЕНО · 01.10.2026

Структурирани изходи и оценка на промпти

Моделът по природа пише свободен текст, а програмата отсреща очаква точни полета. В първата половина превръщаме изхода в проверени данни с JSON схема, Pydantic и Instructor. Във втората спираме да гадаем кой промпт е по-добър и започваме да мерим: A/B тест, модел-съдия, RAGAS и регресионен тест преди всяко пускане.

⏱ 3–4 ч Средно Блок 2 · Част 2/2 структурирани изходи · оценка
Ollama · vLLM🔒 локално Pydantic · Instructor🔒 локално RAGAS · deepeval🔒 локално (с локален модел) Облачен модел като съдия (по избор)🌐 глобален
🔄
ОБНОВЕНО · 01.10.2026 — какво
Библиотеките са сверени с PyPI и официалната документация: Pydantic 2.13.5, Instructor 1.17.0, RAGAS 0.4.3, deepeval 4.2.7, Outlines 1.3.3, ollama-python 0.6.3. Добавено ново стъпало: JSON схема с ограничено генериране направо в сървъра. Ollama приема схема във format, а vLLM — в response_format. Старите параметри guided_json във vLLM са махнати от v0.12.0. Примерът с RAGAS е пренаписан за новия API: ragas.metrics.collections и ascore, защото evaluate() и LangchainLLMWrapper вече са отбелязани като остарели. Добавен е deepeval (GEval). Изключението на Instructor вече се внася от instructor.core. Фактурите са в евро: от 01.01.2026 в България се плаща в евро. Проверката на сумата е в model_validator и е пусната локално. Процентите за надеждност на методите и „реалният резултат“ на счетоводна фирма не можеха да се проверят, затова са махнати или отбелязани като илюстрация. Тестовите данни вече са от измислен вътрешен правилник, а не от „наредби“ с несъществуващи членове.

01Какво ще научиш

02Преди да започнеш

03Стъпки

  1. Защо свободният текст чупи системите

    Представи си анализатор на фактури, написан с text.split("Сума:"). При повечето фактури работи. При останалите или гърми, или тихо записва грешна сума в счетоводството. Защо е опасно? Защото грешката не се вижда, докато някой не сравни числата. Структурираният изход мести проверката там, където ѝ е мястото: в схемата, преди данните да тръгнат нататък.

    СтъпалоКакво гарантираКога
    Инструкция
    „Върни само JSON“
    Нищо. Моделът често добавя обяснения или Markdown ограждения.Бърз прототип
    JSON mode
    format="json" · {"type":"json_object"}
    Синтактично валиден JSON, но с каквито полета реши моделът.Прости структури
    JSON схема
    format=схема · json_schema
    Сървърът ограничава генерирането, за да спазва схемата: имена, типове, изброявания.Почти винаги — стандартът днес
    Схема + Pydantic + повторен опит
    Instructor
    Формата и бизнес правилата. При грешка моделът получава обяснението и опитва пак.Production, пари, документи
    ✅
    Правило: схемата пази формата, валидаторът пази смисъла
    Ограниченото генериране гарантира, че ще получиш число в полето total_amount. То не гарантира, че числото е вярно. Затова аритметиката, датите и регистрите се проверяват в Pydantic.
  2. JSON mode и JSON схема с Ollama и vLLM

    Започваме с данните. Схемата описваме веднъж в Pydantic и я подаваме на сървъра. Ollama я приема в параметъра format, а vLLM — в response_format, както при OpenAI. Температура 0 прави извличането повторяемо.

    Python · schema.py (пуснат локално с Pydantic 2.13.5)
    from datetime import date
    from enum import Enum
    from typing import Optional
    from pydantic import BaseModel, Field, model_validator
    
    class Currency(str, Enum):
        EUR = "EUR"            # от 01.01.2026 в България се плаща в евро
        USD = "USD"
    
    class Invoice(BaseModel):
        vendor_name: str = Field(description="Пълното наименование на доставчика")
        vendor_eik: Optional[str] = Field(default=None, pattern=r"^\d{9}(\d{4})?$",
                                          description="ЕИК/БУЛСТАТ — 9 или 13 цифри")
        invoice_date: date = Field(description="Дата на фактурата")
        amount_without_vat: float = Field(gt=0, description="Данъчна основа")
        vat_rate: float = Field(default=0.20, ge=0, le=1, description="Ставка на ДДС (0.20 = 20%)")
        total_amount: float = Field(gt=0, description="Обща сума с ДДС")
        currency: Currency = Currency.EUR
        description: Optional[str] = None
    
        @model_validator(mode="after")
        def check_total(self):
            # бизнес правило: общо = основа × (1 + ставка)
            expected = round(self.amount_without_vat * (1 + self.vat_rate), 2)
            if abs(self.total_amount - expected) > 0.01:
                raise ValueError(f"Общата сума {self.total_amount} не е равна на {expected:.2f}")
            return self
    ⚠️
    Капан: поле, кръстено като тип
    Старата версия имаше поле date: date. В Pydantic така името на полето засенчва типа и следващите полета с date се чупят. Кръщавай полетата по смисъл: invoice_date.
    Python · extract.py (три нива)
    import json
    import ollama
    from openai import OpenAI
    from schema import Invoice
    
    INVOICE_TEXT = """
    Фактура № 0000001234
    Доставчик: Примерна фирма ООД, ЕИК 123456789
    Дата: 15.03.2026
    Описание: Консултантски услуги за внедряване на AI
    Данъчна основа: 2000.00 EUR
    ДДС 20%: 400.00 EUR
    Общо: 2400.00 EUR
    """   # измислени данни за упражнение
    
    MODEL = "llama3.1:8b"   # или друг модел, изтеглен с ollama pull
    
    # ── Ниво 1: JSON mode — валиден JSON, но полетата не са гарантирани ──
    def extract_json_mode(text: str) -> dict:
        r = ollama.generate(model=MODEL, format="json", options={"temperature": 0},
                            prompt=f"Извлечи данните от фактурата като JSON:\n{text}")
        return json.loads(r["response"])
    
    # ── Ниво 2: JSON схема в Ollama — генерирането следва схемата ──
    def extract_schema_ollama(text: str) -> Invoice:
        r = ollama.chat(model=MODEL,
                        messages=[{"role": "user", "content": f"Извлечи данните от фактурата:\n{text}"}],
                        format=Invoice.model_json_schema(),
                        options={"temperature": 0})
        return Invoice.model_validate_json(r["message"]["content"])   # тук работи и правилото за сумата
    
    # ── Ниво 3: JSON схема във vLLM (OpenAI-съвместим API) ──
    def extract_schema_vllm(text: str) -> Invoice:
        client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
        r = client.chat.completions.create(
            model="llama3",   # --served-model-name от Блок 1
            messages=[{"role": "user", "content": f"Извлечи данните от фактурата:\n{text}"}],
            response_format={"type": "json_schema",
                             "json_schema": {"name": "invoice", "schema": Invoice.model_json_schema()}},
            temperature=0)
        return Invoice.model_validate_json(r.choices[0].message.content)
    
    if __name__ == "__main__":
        print(extract_schema_ollama(INVOICE_TEXT).model_dump_json(indent=2))
    очакван изход
    {
      "vendor_name": "Примерна фирма ООД",
      "vendor_eik": "123456789",
      "invoice_date": "2026-03-15",
      "amount_without_vat": 2000.0,
      "vat_rate": 0.2,
      "total_amount": 2400.0,
      "currency": "EUR",
      "description": "Консултантски услуги за внедряване на AI"
    }
    💡
    Защо пак пишем полетата в промпта
    Документацията на vLLM препоръчва да опишеш схемата и в промпта. Ограничението казва на модела какво не може да напише, а промптът му казва какво трябва. Заедно дават по-добри стойности.
  3. Instructor: типизиран обект и повторни опити

    Instructor обвива клиента на модела. Подаваш му Pydantic модел и получаваш обект от този тип, не речник. Ако валидацията падне, например заради сбъркана сума, Instructor връща грешката на модела и го пита пак. max_retries=3 означава един опит и до три повторни. Ако и те не стигнат, хвърля InstructorRetryException.

    Python · extract_typed.py
    import instructor
    from instructor.core import InstructorRetryException
    from schema import Invoice
    from extract import INVOICE_TEXT
    
    # Ollama: адресът по подразбиране е http://localhost:11434/v1
    client = instructor.from_provider("ollama/llama3.1:8b")
    
    # vLLM или друг OpenAI-съвместим сървър:
    # from openai import OpenAI
    # client = instructor.from_openai(OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed"))
    
    def extract_invoice_typed(text: str) -> Invoice:
        return client.chat.completions.create(
            response_model=Invoice,      # ← Pydantic моделът
            max_retries=3,               # ← 1 опит + до 3 повторни
            messages=[{"role": "user", "content": f"Извлечи данните:\n{text}"}],
            temperature=0,
            # при from_openai добави и model="llama3"
        )
    
    try:
        invoice = extract_invoice_typed(INVOICE_TEXT)
        print(invoice.vendor_name, invoice.invoice_date, invoice.total_amount)
    except InstructorRetryException as e:
        # не приемай, че повторните опити винаги успяват: запиши и прати за ръчен преглед
        print("Неуспех след повторните опити:", e)
    ⚠️
    Капан: режимът на Instructor при локални модели
    При Ollama Instructor избира сам: извикване на инструменти (tools) за модели, които го поддържат, и JSON за останалите. Ако малък модел връща празни или странни полета, опитай изрично mode=instructor.Mode.JSON.
  4. Пример: пакетна обработка на фактури с човешки преглед

    Истинската полза идва, когато моделът не само извлича полета, но и маркира какво да погледне човек. Тук проверяваме задължителните реквизити на фактура по чл. 114 от ЗДДС и предлагаме счетоводна сметка. Последната дума остава за счетоводителя.

    Python · invoice_batch.py
    import json
    from datetime import date
    from pathlib import Path
    from typing import Optional
    import instructor
    from instructor.core import InstructorRetryException
    from pydantic import BaseModel, Field
    
    class InvoiceResult(BaseModel):
        vendor_name: str
        vendor_eik: Optional[str] = None
        invoice_date: date
        total_eur: float = Field(gt=0)
        vat_included: bool
        gl_account: str = Field(description="Предложена счетоводна сметка (напр. 602, 401)")
        requisites_ok: bool = Field(description="True, ако има всички реквизити по чл. 114 ЗДДС")
        issues: list[str] = Field(default_factory=list, description="Липсващи реквизити или съмнения")
    
    SYSTEM = """Ти си помощник на счетоводител. Проверяваш дали фактурата съдържа
    реквизитите по чл. 114 от ЗДДС и предлагаш сметка по сметкоплана.
    Работиш само с текста на фактурата. Не измисляш данни — ако нещо липсва, пиши го в issues."""
    
    client = instructor.from_provider("ollama/llama3.1:8b")
    
    def process_invoice(text: str, name: str) -> Optional[InvoiceResult]:
        try:
            r = client.chat.completions.create(
                response_model=InvoiceResult, max_retries=3, temperature=0,
                messages=[{"role": "system", "content": SYSTEM},
                          {"role": "user", "content": f"Фактура:\n{text}"}])
            mark = "✅" if r.requisites_ok and not r.issues else "👀"
            print(f"{mark} {name}: {r.vendor_name} — {r.total_eur:.2f} EUR")
            return r
        except InstructorRetryException as e:
            print(f"❌ {name}: за ръчна обработка — {e}")
            return None
    
    def process_directory(folder: str) -> None:
        ok, review = [], []
        for f in sorted(Path(folder).glob("*.txt")):
            r = process_invoice(f.read_text(encoding="utf-8"), f.name)
            if r and r.requisites_ok and not r.issues:
                ok.append(r.model_dump(mode="json"))
            else:
                review.append({"file": f.name, "result": r.model_dump(mode="json") if r else None})
        Path("processed.json").write_text(json.dumps(ok, ensure_ascii=False, indent=2), encoding="utf-8")
        Path("for_review.json").write_text(json.dumps(review, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"📊 Автоматично: {len(ok)} · за преглед: {len(review)}")
    💡
    Как да прецениш ползата (илюстрация)
    Ако счетоводител обработва на ръка няколкостотин фактури месечно, а системата отделя за преглед само съмнителните, времето отива за тях, а не за преписване. Колко точно спестява — измери върху своите фактури с тестовия набор от следващите стъпки. Не приемай чужди проценти наготово.
    ⛔
    Фактурите са лични и търговски данни
    Обработвай ги с локален модел 🔒. Ако ползваш облачен модел 🌐, провери договора за обработка на данни и не пращай файлове с лични данни без правно основание.
  5. Какво мерим: четирите основни метрики

    „Този промпт изглежда по-добре“ е мнение. „Промпт Б дава по-висока вярност на нашите 50 тестови случая“ са данни. За да сравняваш, първо се договори какво мериш. Всички метрики по-долу са от 0 до 1.

    МетрикаВъпросОриентир
    Faithfulness (вярност)Подкрепено ли е всяко твърдение в отговора от дадения контекст?колкото по-близо до 1
    Answer relevancy (релевантност)Отговаря ли директно на зададения въпрос?високо
    Context recall (покритие)Намери ли извличането всичко нужно за верния отговор?ниско → проблем в извличането
    Context precision (точност на контекста)Колко от намерените чънкове наистина са полезни и стоят ли най-отгоре?ниско → шум в контекста
    ✅
    Прагове се договарят, не се преписват
    Старата версия даваше „faithfulness над 0.90“ като универсален праг. Такъв праг няма. Измери отправна точка (baseline) на своя набор и реши колко е приемливо за задачата: за правен отговор търпимостта е много по-малка, отколкото за чернова на пост.
  6. A/B тест на два промпта

    Логиката е като при A/B тест на сайт: сменяш едно нещо, пускаш и двата варианта върху едни и същи случаи при температура 0 и решаваш по числата. Тестовите случаи пазиш в git като код. Три случая стигат колкото да видиш как работи; за решение трябват десетки.

    Python · prompt_ab_test.py
    import ollama
    from statistics import mean
    
    MODEL = "llama3.1:8b"
    
    # Измислен вътрешен правилник — учебни данни, не правна справка
    TEST_CASES = [
        {"question": "Колко дни платен отпуск се полагат на нов служител?",
         "context": "Правилник, т. 4.1: Всеки служител има право на 25 работни дни платен отпуск годишно.",
         "ground_truth": "25 работни дни"},
        {"question": "Какво трябва да донеса за достъп до сървърното помещение?",
         "context": "Правилник, т. 7.3: Достъпът изисква служебна карта и писмено одобрение от ръководител.",
         "ground_truth": "служебна карта и писмено одобрение от ръководител"},
        {"question": "В какъв срок се подава отчет за командировка?",
         "context": "Правилник, т. 9.2: Отчетът за командировка се подава до 5 работни дни след връщането.",
         "ground_truth": "до 5 работни дни"},
    ]
    
    PROMPT_A = """Отговори на въпроса на база контекста.
    Контекст: {context}
    Въпрос: {question}"""
    
    PROMPT_B = """Отговаряй САМО от контекста. Ако отговорът липсва, кажи „Не се съдържа в документите.“
    Посочи точката от правилника.
    Контекст: {context}
    Въпрос: {question}
    Кратък и точен отговор:"""
    
    def answer(template: str, case: dict) -> str:
        r = ollama.generate(model=MODEL, prompt=template.format(**case), options={"temperature": 0})
        return r["response"].strip()
    
    def score(ans: str, case: dict) -> dict:
        """Груби евристики за бърз ориентир; за решение ползвай модел-съдия или RAGAS."""
        gt = set(case["ground_truth"].lower().split())
        a = set(ans.lower().split())
        ctx = set(case["context"].lower().split())
        accuracy = len(gt & a) / len(gt) if gt else 0.0
        extra = a - ctx - gt
        faithfulness = 1.0 - min(1.0, len(extra) / max(1, len(a)))
        conciseness = 1.0 if len(ans.split()) < 50 else 0.7
        return {"accuracy": accuracy, "faithfulness": faithfulness, "conciseness": conciseness,
                "composite": (accuracy + faithfulness + conciseness) / 3}
    
    def run(template: str) -> dict:
        rows = [score(answer(template, c), c) for c in TEST_CASES]
        return {k: round(mean(r[k] for r in rows), 3) for k in rows[0]}
    
    if __name__ == "__main__":   # за да може regression_test.py да внася данните без да пуска теста
        for name, tpl in (("Промпт А", PROMPT_A), ("Промпт Б", PROMPT_B)):
            res = run(tpl)
            print(name, " ".join(f"{k}={v:.3f}" for k, v in res.items()))
    ⚠️
    Капан: победа от шума
    Старата версия показваше „Промпт Б печели с +24%“ като очакван резултат. Това е илюстрация, не измерване. На малък набор разлика от няколко процента може да е случайна. Гледай и отделните случаи: ако Б печели средно, но проваля важен случай, той не е победител.
  7. Модел-съдия (LLM-as-Judge)

    Ръчната оценка на стотици отговори отнема часове. Затова по-силен модел оценява отговорите на по-слаб по ясна рубрика. Оценката също е структуриран изход: съдията връща числа в граници и кратка обосновка, а не есе.

    Python · llm_judge.py
    import instructor
    import ollama
    from pydantic import BaseModel, Field
    
    class JudgeScore(BaseModel):
        faithfulness: float = Field(ge=0, le=1, description="Всичко ли е подкрепено от контекста")
        relevance: float = Field(ge=0, le=1, description="Отговаря ли директно на въпроса")
        completeness: float = Field(ge=0, le=1, description="Пропуснато ли е нещо важно")
        reasoning: str = Field(description="Обосновка в 1–2 изречения")
    
    JUDGE_SYSTEM = """Ти оценяваш отговори на RAG система по три критерия от 0.0 до 1.0.
    FAITHFULNESS: 1.0 = всичко е от контекста · 0.5 = половината е предположение · 0.0 = игнорира контекста
    RELEVANCE:    1.0 = директен, точен отговор · 0.5 = частично · 0.0 = извън темата
    COMPLETENESS: 1.0 = всичко важно е включено · 0.5 = липсват детайли · 0.0 = липсват ключови данни
    Бъди строг. Първо обоснови, после дай числата."""
    
    JUDGE_MODEL = "llama3.1:70b"    # по-силен от оценявания
    ANSWER_MODEL = "llama3.1:8b"
    judge = instructor.from_provider(f"ollama/{JUDGE_MODEL}")
    
    def judge_answer(question: str, context: str, answer: str) -> JudgeScore:
        return judge.chat.completions.create(
            response_model=JudgeScore, max_retries=2, temperature=0,
            messages=[{"role": "system", "content": JUDGE_SYSTEM},
                      {"role": "user", "content": f"ВЪПРОС: {question}\nКОНТЕКСТ:\n{context}\nОТГОВОР:\n{answer}"}])
    
    def evaluate_dataset(cases: list[dict]) -> float:
        total = []
        for c in cases:
            ans = ollama.generate(model=ANSWER_MODEL, options={"temperature": 0},
                                  prompt=f"Контекст: {c['context']}\nВъпрос: {c['question']}")["response"]
            s = judge_answer(c["question"], c["context"], ans)
            comp = (s.faithfulness + s.relevance + s.completeness) / 3
            total.append(comp)
            flag = "✅" if comp > 0.8 else ("⚠️" if comp > 0.6 else "❌")
            print(f"{flag} {comp:.2f} | {s.reasoning[:70]}")
        avg = sum(total) / len(total)
        print(f"📊 Средно: {avg:.3f}")
        return avg
    ⚠️
    Капан: съдията също греши
    Изследванията върху модели-съдии описват устойчиви изкривявания: предпочитат по-дългите отговори, първия от два варианта и отговорите на собствения си модел. Затова: разменяй реда при сравнение по двойки, давай строга рубрика и сверявай поне 20–30 оценки с човек, преди да се довериш.
  8. RAGAS: готови метрики за RAG

    RAGAS е специализиран за RAG и изчислява метриките от стъпка 5 с модел-съдия и embeddings. От версия 0.4 метриките се внасят от ragas.metrics.collections и се викат поотделно с ascore. С локален Ollama нищо не излиза навън.

    Python · ragas_eval.py (RAGAS 0.4.3)
    import asyncio
    from openai import AsyncOpenAI
    from ragas.llms import llm_factory
    from ragas.embeddings import embedding_factory
    from ragas.metrics.collections import Faithfulness, AnswerRelevancy, ContextRecall, ContextPrecision
    
    # Ollama през OpenAI-съвместимия адрес — без облак
    ollama_client = AsyncOpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
    llm = llm_factory("llama3.1:70b", client=ollama_client, temperature=0)   # съдията
    emb = embedding_factory("openai", model="nomic-embed-text", client=ollama_client)
    
    metrics = {
        "faithfulness": Faithfulness(llm=llm),
        "answer_relevancy": AnswerRelevancy(llm=llm, embeddings=emb),
        "context_recall": ContextRecall(llm=llm),
        "context_precision": ContextPrecision(llm=llm),
    }
    
    SAMPLES = [   # измислени учебни данни
        {"user_input": "Колко дни платен отпуск се полагат?",
         "response": "Полагат се 25 работни дни платен отпуск годишно (т. 4.1).",
         "retrieved_contexts": ["Правилник, т. 4.1: Всеки служител има право на 25 работни дни платен отпуск годишно."],
         "reference": "25 работни дни годишно"},
    ]
    
    async def main():
        for s in SAMPLES:
            f = await metrics["faithfulness"].ascore(user_input=s["user_input"], response=s["response"],
                                                     retrieved_contexts=s["retrieved_contexts"])
            a = await metrics["answer_relevancy"].ascore(user_input=s["user_input"], response=s["response"])
            r = await metrics["context_recall"].ascore(user_input=s["user_input"], reference=s["reference"],
                                                       retrieved_contexts=s["retrieved_contexts"])
            p = await metrics["context_precision"].ascore(user_input=s["user_input"], reference=s["reference"],
                                                          retrieved_contexts=s["retrieved_contexts"])
            print(f"{s['user_input'][:40]} | faith={f.value:.2f} rel={a.value:.2f} "
                  f"recall={r.value:.2f} prec={p.value:.2f}")
    
    asyncio.run(main())
    ⚠️
    Капан: стари примери за RAGAS
    Повечето примери в интернет ползват from ragas.metrics import faithfulness, evaluate(dataset=...) и LangchainLLMWrapper. В 0.4 те още работят, но с предупреждение, че ще бъдат махнати. Колоните също са преименувани: question → user_input, answer → response, contexts → retrieved_contexts, ground_truth → reference.
    🔑
    Кога RAGAS, кога собствен съдия
    RAGAS — когато имаш работеща RAG система и искаш стандартни, сравними метрики. За context_recall ти трябва еталонен отговор (reference). Собствен съдия — когато прототипираш или мериш нещо специфично за областта ти, например „цитира ли точно точката от правилника“. Там не е нужен еталон.
  9. deepeval: собствен критерий с GEval

    deepeval прави оценката да изглежда като unit тест. GEval приема критерий на обикновен език и сам го превръща в стъпки за съдията. Удобно е, когато критерият е твой и не е сред стандартните.

    Python · test_answers.py (deepeval 4.2.7)
    from deepeval.metrics import GEval
    from deepeval.models import OllamaModel
    from deepeval.test_case import LLMTestCase, SingleTurnParams
    
    judge = OllamaModel(model="llama3.1:70b", base_url="http://localhost:11434", temperature=0)
    
    cites_rule = GEval(
        name="Цитира точката",
        criteria="Отговорът посочва точката от правилника, на която се основава, и не твърди нищо извън контекста.",
        evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT,
                           SingleTurnParams.RETRIEVAL_CONTEXT],
        model=judge,
        threshold=0.7,
    )
    
    case = LLMTestCase(
        input="Колко дни платен отпуск се полагат?",
        actual_output="Полагат се 25 работни дни годишно (т. 4.1).",
        retrieval_context=["Правилник, т. 4.1: Всеки служител има право на 25 работни дни платен отпуск годишно."],
    )
    
    cites_rule.measure(case)
    print(cites_rule.score, cites_rule.reason)

    В по-стари примери ще видиш LLMTestCaseParams. Името е сменено на SingleTurnParams; старото още работи, но с предупреждение.

  10. Регресионен тест: спирачка преди пускане

    Всяка промяна на промпт или модел подобрява едни случаи и може да влоши други. Регресионният тест сравнява новия резултат със записаната отправна точка и спира пускането, ако спадът е над прага. Отправната точка се обновява само при успех.

    Python · regression_test.py
    import json
    import sys
    from datetime import datetime
    from pathlib import Path
    import ollama
    from llm_judge import judge_answer
    from prompt_ab_test import TEST_CASES, PROMPT_B
    
    MODEL = "llama3.1:8b"
    BASELINE = Path("baseline_scores.json")
    THRESHOLD = 0.05   # допустим спад: 0.05 от скалата 0–1
    
    def run(prompt: str) -> list[float]:
        out = []
        for c in TEST_CASES:
            ans = ollama.generate(model=MODEL, prompt=prompt.format(**c), options={"temperature": 0})["response"]
            s = judge_answer(c["question"], c["context"], ans)
            out.append((s.faithfulness + s.relevance + s.completeness) / 3)
        return out
    
    def main(prompt: str) -> bool:
        scores = run(prompt)
        avg = sum(scores) / len(scores)
        if BASELINE.exists():
            base = json.loads(BASELINE.read_text(encoding="utf-8"))["avg_score"]
            delta = avg - base
            print(f"Отправна точка: {base:.3f} · нов: {avg:.3f} · разлика: {delta:+.3f}")
            if delta < -THRESHOLD:
                print("❌ РЕГРЕСИЯ — пускането се спира")
                return False
        else:
            print("ℹ️ Няма отправна точка — записваме текущия резултат")
        BASELINE.write_text(json.dumps({"avg_score": avg, "n_cases": len(scores), "scores": scores,
                                        "timestamp": datetime.now().isoformat()}, indent=2), encoding="utf-8")
        return True
    
    if __name__ == "__main__":
        sys.exit(0 if main(PROMPT_B) else 1)   # код 1 спира CI/CD
    🛡️
    Какво поправихме спрямо старата версия
    Старият скрипт ползваше ollama и judge_answer, без да ги внася, и записваше новия резултат като отправна точка дори при регресия. Сега при провал старата отправна точка остава.

04Проверка

Чеклист

Тест

1. Instructor е с max_retries=3 и валидацията пада при всеки опит. Какво става накрая?

2. RAGAS дава context_recall = 0.42. Коя е най-вероятната причина?

3. Нов промпт вдига faithfulness, но сваля answer relevancy. Какво показва това?

4. Кое кара изхода да следва JSON схемата още докато моделът генерира?

05Какво следва

С това Блок 2 е завършен: техниките на промптинг, системните промпти, структурираните изходи и оценката с данни.

06Източници

  1. Ollama: структурирани изходи — format="json" и JSON схема.
  2. vLLM: Structured Outputs — response_format, structured_outputs, махнатите guided_*.
  3. Pydantic: валидатори — field_validator и model_validator.
  4. Instructor: документация и интеграция с Ollama.
  5. Outlines — ограничено генериране, когато моделът върви в твоя процес.
  6. RAGAS: метрики — faithfulness, answer relevancy, context recall и precision.
  7. deepeval: първи стъпки — тестове и GEval.
  8. Es et al. (2023). Ragas: Automated Evaluation of Retrieval Augmented Generation — статията, въвела метриките.
  9. Zheng et al. (2023). Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena — изкривяванията на модела-съдия.