210–270 de minute · Ubuntu/Kubuntu · Nivel: începător · Recomandat: Laboratorul 1

Ce construim și ce trebuie să înțelegi

În acest laborator transformăm noțiunile din Lecția 2 într-un program pe care îl poți urmări pas cu pas. Construim un agent determinist pentru verificarea unei facturi fictive: citește datele facturii, verifică dacă informațiile necesare există și au forma așteptată, calculează totalurile, pregătește un pachet pentru verificarea umană și decide dacă trebuie să continue, să aștepte, să se blocheze sau să se oprească.

Scopul principal nu este contabilitatea. Factura este doar un exemplu familiar, cu date ușor de verificat manual. Scopul real este să vezi cum arată în cod bucla de control a unui agent: observație → alegerea unei acțiuni → executarea acțiunii → actualizarea stării → o nouă observație.

Ce trebuie să poți explica la final

FACTURĂ (date externe)
        ↓
OBSERVĂ: ce lipsește? ce este invalid? ce s-a calculat deja?
        ↓
DECIDE: care este următoarea acțiune permisă?
        ↓
EXECUTĂ: validează / calculează / pregătește / așteaptă
        ↓
ACTUALIZEAZĂ STAREA
        ↓
OBSERVĂ DIN NOU
        ↺
OPRIRE: verificare umană / aprobare / blocare / finalizare / eșec

Programul nu folosește încă un LLM. Alegerea este intenționată: dacă bucla este deterministă, putem vedea exact de ce a fost aleasă fiecare acțiune și putem demonstra prin teste că o regulă de siguranță nu este ocolită. Mai târziu, un model va putea prelua acele decizii pentru care regulile fixe nu sunt suficiente — de exemplu, când trebuie interpretat limbaj natural sau o situație ambiguă.

Important: laboratorul folosește o factură doar ca exemplu didactic. Codul nu este un validator fiscal, nu verifică toate cerințele unei facturi reale și nu comunică cu RO e-Factura.

1. Recapitulează instrumentele folosite

Dacă ai terminat Laboratorul 1, majoritatea instrumentelor sunt deja instalate. Nu le învățăm din nou de la zero; verificăm rolul fiecăruia și adăugăm Graphviz, pe care îl folosim pentru diagrama agentului.

Python 3

Limbajul în care scriem agentul și testele. În această lecție folosim numai biblioteca standard Python: nu instalăm biblioteci Python externe.

Git

Păstrează istoricul proiectului. Laboratorul 2 continuă același repository Git creat în Laboratorul 1.

jq

Citește și filtrează JSON în terminal. Îl folosim atât pentru a verifica fișierele de intrare, cât și pentru a afișa numai câmpurile importante din rezultatul agentului.

tree

Afișează structura folderelor. Este un instrument de orientare: te ajută să verifici rapid dacă fișierele au ajuns în locurile corecte.

Graphviz

Transformă o descriere text în format DOT într-o diagramă PNG. Diagrama nu controlează agentul; documentează vizual aceeași buclă pe care o implementează codul.

Dacă ai făcut Laboratorul 1

Nu este nevoie să recreezi proiectul de la zero. Folosește același folder ~/ai-agents-zero-to-expert, același mediu .venv și același repository Git. Laboratorul 2 adaugă fișiere noi peste structura deja creată.

2. Verifică instalarea

Mai întâi ne asigurăm că instrumentele există. Dacă ai rulat deja Laboratorul 1, comanda de instalare este sigură: apt va păstra pachetele care sunt deja instalate și va adăuga doar ce lipsește, în special Graphviz.

Ce fac comenzile

sudo apt update
sudo apt install -y python3 python3-venv python3-pip git jq tree graphviz

Apoi cerem fiecărui program să își afișeze versiunea:

python3 --version
git --version
jq --version
tree --version
dot -V
Ce trebuie să observi: fiecare comandă trebuie să afișeze un nume și un număr de versiune. Graphviz poate scrie versiunea pe canalul standard de eroare (stderr); dacă vezi o versiune după dot -V, verificarea a reușit.

3. Continuă proiectul cumulativ

Toate laboratoarele folosesc același folder principal. Astfel, ceea ce construiești într-o lecție devine baza lecției următoare, în loc să creezi proiecte izolate care nu se mai întâlnesc.

mkdir -p ~/ai-agents-zero-to-expert
cd ~/ai-agents-zero-to-expert
pwd

mkdir -p creează folderul dacă lipsește și nu produce eroare dacă există deja. cd intră în folder, iar pwd îți arată unde te afli. Rezultatul trebuie să se termine cu /ai-agents-zero-to-expert.

Creează subfolderele de care are nevoie Laboratorul 2. Păstrăm și folderele introduse în Laboratorul 1, pentru ca proiectul să rămână coerent chiar dacă cineva începe direct de aici:

mkdir -p \
  src \
  tests \
  configs \
  data/inbox \
  data/archive \
  diagrams \
  logs/lesson02 \
  docs/lessons/lesson-02 \
  docs/architecture-decisions \
  skills \
  evaluations
src/codul agentului
tests/teste automate
configs/setări separate de cod
data/inbox/facturile demonstrative
diagrams/sursa DOT și imaginea PNG
logs/lesson02/jurnale generate la rulare
docs/proiectare și decizii arhitecturale

Bara inversă \ de la sfârșitul unei linii îi spune shell-ului că aceeași comandă continuă pe linia următoare. Nu sunt mai multe comenzi mkdir; este una singură, scrisă pe mai multe linii pentru lizibilitate.

4. Activează mediul Python

Mediul virtual .venv izolează Python-ul proiectului. Dacă l-ai creat în Laboratorul 1, nu îl recrea: doar activează-l. Dacă începi direct cu Laboratorul 2, creează-l o singură dată.

Alege situația ta

python3 -m venv .venv   # rulează această linie numai dacă .venv nu există
source .venv/bin/activate
python -m pip install --upgrade pip

După activare, promptul terminalului ar trebui să înceapă cu (.venv). Confirmă că Python vine din proiect:

which python
python --version

Calea afișată de which python trebuie să conțină .venv/bin/python. Această verificare contează: dacă folosești accidental Python-ul global al sistemului, mai târziu poți instala dependențe în locul greșit.

5. Verifică Git și protecțiile proiectului

Laboratorul 2 continuă același repository Git. Dacă rulezi git init într-un proiect deja inițializat, Git poate spune că a reinițializat repository-ul; nu pierzi istoricul.

git config --global init.defaultBranch main
git init

Fișierul .gitignore trebuie să păstreze protecțiile din Laboratorul 1 și să ignore jurnalele noi. Creează sau înlocuiește conținutul cu:

cat > .gitignore <<'EOF'
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.env
.env.*
logs/*
!logs/.gitkeep
*.tmp
.DS_Store
EOF

touch logs/.gitkeep

.gitignore nu șterge fișiere și nu le criptează. El doar îi spune lui Git ce să nu includă accidental într-un commit. Ignorăm .env deoarece poate conține secrete și ignorăm jurnalele deoarece pot conține date de rulare. Fișierul gol logs/.gitkeep permite păstrarea structurii folderului în Git.

Legătura cu Laboratorul 1: fișierul de mai jos păstrează toate regulile anterioare și adaugă protecțiile necesare acestui laborator. O lecție nouă nu trebuie să elimine protecții deja introduse în proiect.

6. Creează configurația agentului

Separăm configurația de cod pentru ca o regulă de operare să poată fi schimbată fără să rescriem funcțiile Python. În acest fișier definim obiectivul, câmpurile obligatorii, cota folosită în exemplu, limita de pași și regula de aprobare.

Creează fișierul direct din terminal:

cat > configs/invoice_agent.json <<'JSON'
{
  "agent_name": "lesson02-invoice-review-agent",
  "goal": "prepare_invoice_for_human_review",
  "required_fields": ["seller", "customer", "invoice_number", "currency", "items"],
  "tax_rate": "0.21",
  "max_steps": 12,
  "sending_requires_approval": true
}
JSON

Cum citești această configurație

Verifică faptul că fișierul este JSON valid și apoi citește numai obiectivul:

jq . configs/invoice_agent.json
jq -r '.goal' configs/invoice_agent.json

Prima comandă trebuie să afișeze obiectul formatat. A doua trebuie să afișeze prepare_invoice_for_human_review. Dacă prima comandă eșuează, nu continua până nu corectezi JSON-ul.

Despre TVA: valoarea 0.21 este folosită ca exemplu didactic contemporan. Codul nu stabilește legal ce cotă se aplică unei operațiuni și nu trebuie folosit ca motor fiscal.

7. Creează datele de intrare și înțelege fiecare scenariu

Un agent nu se verifică numai cu un exemplu „perfect”. Folosim cinci facturi fictive, fiecare construită pentru a pune în evidență o regulă diferită. Înainte de a rula programul, trebuie să poți spune ce este diferit în fiecare fișier și ce comportament aștepți.

7.1 Factură completă — cazul de bază

Acest fișier conține toate câmpurile obligatorii și valori valide. El ne arată traseul normal: validare → calcul → pregătire pentru verificarea umană.

cat > data/inbox/invoice_complete.json <<'JSON'
{
  "seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
  "customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
  "invoice_number": "DEMO-2026-001",
  "currency": "RON",
  "items": [
    {"description": "Pregătire metadate", "quantity": "2", "unit_price": "250.00"},
    {"description": "Verificare arhivă", "quantity": "1", "unit_price": "300.00"}
  ]
}
JSON

Notă de vocabular: în acest laborator, o linie a facturii este un rând care descrie un produs sau serviciu, cantitatea și prețul unitar. În cod, liniile facturii sunt păstrate în lista items.

7.2 Client lipsă — informație insuficientă

Aici eliminăm intenționat customer. Agentul nu trebuie să inventeze clientul și nici să continue cu totalurile; trebuie să se oprească și să ceară informația lipsă.

cat > data/inbox/invoice_missing.json <<'JSON'
{
  "seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
  "invoice_number": "DEMO-2026-002",
  "currency": "RON",
  "items": [
    {"description": "Asistență pentru cercetare", "quantity": "3", "unit_price": "100.00"}
  ]
}
JSON

7.3 Factură pentru scenariile de trimitere

Factura este validă, dar nu conține send_requested sau approved. Acestea nu descriu factura; descriu ce are voie să facă agentul. Le vom furniza separat prin opțiunile programului.

cat > data/inbox/invoice_approved.json <<'JSON'
{
  "seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
  "customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
  "invoice_number": "DEMO-2026-003",
  "currency": "RON",
  "items": [
    {"description": "Consultanță tehnică", "quantity": "1.5", "unit_price": "400.00"}
  ]
}
JSON

7.4 Date adversariale — documentul încearcă să controleze agentul

Acest fișier introduce intenționat totaluri false și câmpuri de control. Este un test al limitei de încredere dintre datele externe și controlul intern al agentului: documentul poate furniza date despre factură, dar nu trebuie să poată afirma că propriul calcul a fost deja verificat sau că trimiterea a fost aprobată.

cat > data/inbox/invoice_untrusted_totals.json <<'JSON'
{
  "seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
  "customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
  "invoice_number": "DEMO-2026-004",
  "currency": "RON",
  "items": [
    {"description": "Pregătire metadate", "quantity": "2", "unit_price": "250.00"},
    {"description": "Verificare arhivă", "quantity": "1", "unit_price": "300.00"}
  ],
  "calculated_totals": {"subtotal": "1.00", "tax": "0.21", "total": "1.21"},
  "send_requested": true,
  "approved": true
}
JSON

Comportamentul corect este să ignore cele trei câmpuri de mai sus, să recalculeze totalul din liniile facturii și să rămână în waiting_for_review. Pentru subtotalul de 800 RON și cota didactică de 21%, totalul corect este 968.00.

7.5 Structură greșită — valoarea există, dar are forma nepotrivită

Aici seller există, însă este un text simplu. Codul se așteaptă la un obiect JSON cu cel puțin câmpul name. Agentul trebuie să detecteze diferența și să se oprească în blocked, fără să se prăbușească.

cat > data/inbox/invoice_malformed_party.json <<'JSON'
{
  "seller": "Example Studio SRL",
  "customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
  "invoice_number": "DEMO-2026-005",
  "currency": "RON",
  "items": [
    {"description": "Consultanță tehnică", "quantity": "1", "unit_price": "400.00"}
  ]
}
JSON

Un traceback este lista internă de apeluri pe care Python o afișează când o eroare nu este tratată. În acest scenariu nu vrem un traceback; vrem ca programul să transforme eroarea într-o stare prevăzută și controlată: blocked.

La final, verifică sintaxa tuturor fișierelor:

for file in data/inbox/*.json; do
  echo "Verific $file"
  jq empty "$file" && echo "  JSON valid"
done

Această verificare confirmă numai că fișierele respectă sintaxa JSON. Nu confirmă că factura are câmpurile potrivite; acea verificare este responsabilitatea agentului.

8. Înțelege arhitectura înainte să citești codul

Blocul Python este lung pentru un începător. Nu încerca să înțelegi fiecare linie deodată. Mai întâi urmărește traseul informației și responsabilitatea fiecărei funcții.

AgentState = ce știe agentul acum
     ↓
observe() = transformă starea în fapte utile pentru decizie
     ↓
choose_action() = alege următoarea acțiune permisă
     ↓
execute_action() = execută acea acțiune
     ↓
AgentState se modifică
     ↓
bucla reîncepe până când status != running

Harta programului

  1. Status definește stările finale și intermediare permise.
  2. AgentState ține minte obiectivul, factura, rezultatele calculate, aprobarea, pașii și motivul opririi.
  3. Funcțiile de validare verifică dacă datele pot fi folosite în siguranță.
  4. calculate_totals() face aritmetica exactă.
  5. prepare_review_package() construiește rezultatul care va fi verificat de un om.
  6. observe() răspunde la întrebarea „care este situația acum?”.
  7. choose_action() răspunde la întrebarea „ce trebuie făcut în continuare?”.
  8. execute_action() răspunde la întrebarea „cum executăm acțiunea aleasă?”.
  9. run_agent() este bucla care repetă observarea, decizia și acțiunea.
  10. write_trace() păstrează jurnalul cronologic al pașilor.

8.1 Date observate versus rezultate produse de agent

Factura este documentul-sursă pe care agentul îl examinează: de aici citește părțile și liniile facturii. Totalul recalculat este însă un rezultat derivat, adică o valoare produsă de program din datele sursă. Dacă am păstra datele primite și rezultatele calculate în același loc și am avea încredere în orice câmp primit, documentul ar putea pretinde că verificarea a avut deja loc.

De aceea build_state() copiază factura și elimină câmpurile de control sau rezultatele derivate pe care sursa încearcă să le introducă: calculated_totals, simulated_send, simulated_send_record, send_requested și approved. Valorile corecte sunt păstrate separat în AgentState.

8.2 Cererea de trimitere și aprobarea sunt intrări de control

În laborator, --request-send arată că cineva solicită trimiterea, iar --approve-send simulează aprobarea unei persoane autorizate. Acestea sunt semnale de control furnizate separat de datele facturii. Sunt încă simplificări didactice, dar vin din afara documentului verificat. Într-un sistem real, aprobarea ar proveni dintr-o identitate autentificată și ar fi înregistrată împreună cu cine a aprobat, ce anume și când.

Ideea pe care trebuie să o reții: documentul analizat furnizează informații despre problemă; nu primește automat dreptul de a controla agentul care îl analizează.

9. Creează programul și verifică-i sintaxa

Acum transpunem arhitectura în Python. Salvează codul în src/lesson02_invoice_agent.py. Programul nu contactează rețeaua, nu modifică factura-sursă și nu efectuează o trimitere reală.

Cum să citești codul fără să te pierzi

  1. Citește mai întâi numai Status și AgentState: acestea îți spun ce poate ține minte agentul.
  2. Citește funcțiile de validare și calculate_totals(): acestea sunt instrumentele deterministe.
  3. Citește observe(), choose_action() și execute_action() împreună.
  4. Abia la final citește run_agent(); vei vedea că bucla este scurtă tocmai pentru că responsabilitățile au fost separate.
Termeni Python pe care îi vei vedea în cod

@dataclass creează o clasă potrivită pentru păstrarea datelor. Enum limitează o valoare la un set cunoscut de opțiuni. Decimal este folosit pentru bani, deoarece evită unele erori de reprezentare ale numerelor zecimale în float. Adnotările precum list[str] descriu tipul așteptat și fac programul mai ușor de citit; Python nu le transformă automat în validări.

#!/usr/bin/env python3
"""Agent determinist și inspectabil pentru laboratorul Lecției 2.

Factura este tratată ca sursă externă de date și nu poate furniza starea de
aprobare a agentului. Totalurile calculate, rezultatele validării și acțiunile
simulate sunt păstrate separat în AgentState. Programul nu contactează servicii
externe.
"""

from __future__ import annotations

import argparse
import copy
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from enum import Enum
from pathlib import Path
from typing import Any


MONEY_QUANTUM = Decimal("0.01")


class Status(str, Enum):
    RUNNING = "running"
    WAITING_FOR_INPUT = "waiting_for_input"
    WAITING_FOR_REVIEW = "waiting_for_review"
    WAITING_FOR_APPROVAL = "waiting_for_approval"
    BLOCKED = "blocked"
    COMPLETED = "completed"
    FAILED = "failed"


@dataclass
class TraceEntry:
    step: int
    observation: dict[str, Any]
    selected_action: str
    result: dict[str, Any]
    status_after_action: str


@dataclass
class AgentState:
    goal: str
    invoice: dict[str, Any]
    required_fields: list[str]
    tax_rate: Decimal
    max_steps: int
    sending_requires_approval: bool
    send_requested: bool = False
    approved: bool = False
    status: Status = Status.RUNNING
    step_count: int = 0
    actions_taken: list[str] = field(default_factory=list)
    missing_fields: list[str] = field(default_factory=list)
    validation_errors: list[str] = field(default_factory=list)
    calculated_totals: dict[str, str] | None = None
    review_package: dict[str, Any] | None = None
    simulated_send_record: dict[str, Any] | None = None
    ignored_input_fields: list[str] = field(default_factory=list)
    stop_reason: str | None = None
    trace: list[TraceEntry] = field(default_factory=list)


def load_json(path: Path) -> dict[str, Any]:
    """Citește un obiect JSON și respinge fișierele lipsă, invalide sau de alt tip."""
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise ValueError(f"Fișierul nu există: {path}") from exc
    except json.JSONDecodeError as exc:
        raise ValueError(f"JSON invalid în {path}: {exc}") from exc

    if not isinstance(data, dict):
        raise ValueError(f"Fișierul {path} trebuie să conțină un obiect JSON")
    return data


def quantize_money(value: Decimal) -> Decimal:
    return value.quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP)


def money(value: Decimal) -> str:
    return format(quantize_money(value), ".2f")


def inspect_missing_fields(state: AgentState) -> list[str]:
    missing: list[str] = []
    for field_name in state.required_fields:
        value = state.invoice.get(field_name)
        if value is None or value == "" or value == [] or value == {}:
            missing.append(field_name)
    return missing


def validate_metadata(state: AgentState) -> dict[str, Any]:
    errors: list[str] = []
    invoice_number = state.invoice.get("invoice_number")
    currency = state.invoice.get("currency")

    if invoice_number is not None and (
        not isinstance(invoice_number, str) or not invoice_number.strip()
    ):
        errors.append("invoice_number trebuie să fie un text nevid")
    if currency is not None and (
        not isinstance(currency, str) or not currency.strip()
    ):
        errors.append("currency trebuie să fie un text nevid")

    return {"valid": not errors, "errors": errors}


def validate_parties(state: AgentState) -> dict[str, Any]:
    """Validează vânzătorul și clientul înainte de accesarea câmpurilor lor."""
    errors: list[str] = []
    for party_name in ("seller", "customer"):
        party = state.invoice.get(party_name)
        if party is None:
            continue  # Câmpurile de nivel superior lipsă sunt tratate separat.
        if not isinstance(party, dict):
            errors.append(f"{party_name} trebuie să fie un obiect JSON")
            continue
        name = party.get("name")
        if not isinstance(name, str) or not name.strip():
            errors.append(f"{party_name}.name este obligatoriu și trebuie să fie text")
    return {"valid": not errors, "errors": errors}


def _decimal_field(value: Any, label: str) -> tuple[Decimal | None, str | None]:
    try:
        number = Decimal(str(value))
    except (InvalidOperation, TypeError, ValueError):
        return None, f"{label} nu este un număr valid"
    if not number.is_finite():
        return None, f"{label} trebuie să fie un număr finit"
    return number, None


def validate_items(state: AgentState) -> dict[str, Any]:
    items = state.invoice.get("items")
    if not isinstance(items, list) or not items:
        return {"valid": False, "errors": ["items trebuie să fie o listă nevidă"]}

    errors: list[str] = []
    for index, item in enumerate(items, start=1):
        if not isinstance(item, dict):
            errors.append(f"poziția {index} trebuie să fie un obiect JSON")
            continue

        description = item.get("description")
        if not isinstance(description, str) or not description.strip():
            errors.append(f"poziția {index}: description este obligatoriu și trebuie să fie text")

        quantity, quantity_error = _decimal_field(
            item.get("quantity"), f"poziția {index}: quantity"
        )
        unit_price, price_error = _decimal_field(
            item.get("unit_price"), f"poziția {index}: unit_price"
        )
        if quantity_error:
            errors.append(quantity_error)
        if price_error:
            errors.append(price_error)
        if quantity is not None and quantity <= 0:
            errors.append(f"poziția {index}: quantity trebuie să fie mai mare decât zero")
        if unit_price is not None and unit_price < 0:
            errors.append(f"poziția {index}: unit_price nu poate fi negativ")

    return {"valid": not errors, "errors": errors}


def calculate_totals(state: AgentState) -> dict[str, Any]:
    subtotal = Decimal("0.00")
    for item in state.invoice["items"]:
        quantity = Decimal(str(item["quantity"]))
        unit_price = Decimal(str(item["unit_price"]))
        line_total = quantize_money(quantity * unit_price)
        subtotal += line_total

    subtotal = quantize_money(subtotal)
    tax = quantize_money(subtotal * state.tax_rate)
    total = subtotal + tax
    state.calculated_totals = {
        "subtotal": money(subtotal),
        "tax_rate": str(state.tax_rate),
        "tax": money(tax),
        "total": money(total),
    }
    return {"ok": True, "totals": state.calculated_totals}


def prepare_review_package(state: AgentState) -> dict[str, Any]:
    if state.calculated_totals is None:
        raise RuntimeError("pachetul pentru verificare necesită totaluri calculate de agent")

    seller = state.invoice["seller"]
    customer = state.invoice["customer"]
    package = {
        "invoice_number": state.invoice["invoice_number"],
        "seller_name": seller.get("name"),
        "customer_name": customer.get("name"),
        "currency": state.invoice["currency"],
        "calculated_totals": state.calculated_totals,
        "human_review_required": True,
        "send_requested": state.send_requested,
        "approval_required_for_send": (
            state.send_requested and state.sending_requires_approval
        ),
        "external_send_performed": False,
    }
    state.review_package = package
    return {"ok": True, "review_package": package}


def simulate_send(state: AgentState) -> dict[str, Any]:
    # Apărare în profunzime: executorul verifică din nou condițiile importante,
    # chiar dacă politica de acțiune ar trebui să fi făcut deja verificarea.
    if not state.send_requested:
        raise RuntimeError("trimiterea simulată nu a fost solicitată")
    if state.sending_requires_approval and not state.approved:
        raise RuntimeError("trimiterea simulată necesită aprobare explicită")

    state.simulated_send_record = {
        "performed": True,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "note": "Nu a fost contactat niciun serviciu extern.",
    }
    state.status = Status.COMPLETED
    state.stop_reason = "trimiterea simulată aprobată a fost finalizată"
    return {"ok": True, "external_side_effect": False}


def observe(state: AgentState) -> dict[str, Any]:
    missing = inspect_missing_fields(state)
    state.missing_fields = missing

    metadata_check = validate_metadata(state)
    party_check = validate_parties(state)
    item_check = (
        validate_items(state)
        if "items" not in missing
        else {"valid": False, "errors": ["items nu este disponibil"]}
    )
    state.validation_errors = (
        metadata_check["errors"] + party_check["errors"] + item_check["errors"]
    )

    return {
        "missing_fields": missing,
        "metadata_valid": metadata_check["valid"],
        "metadata_errors": metadata_check["errors"],
        "parties_valid": party_check["valid"],
        "party_errors": party_check["errors"],
        "items_valid": item_check["valid"],
        "item_errors": item_check["errors"],
        "totals_present": state.calculated_totals is not None,
        "review_present": state.review_package is not None,
        "send_requested": state.send_requested,
        "approved": state.approved,
        "ignored_input_fields": list(state.ignored_input_fields),
        "step_count": state.step_count,
        "max_steps": state.max_steps,
    }


def choose_action(state: AgentState, observation: dict[str, Any]) -> str:
    if observation["missing_fields"]:
        return "request_missing_information"
    if not observation["metadata_valid"]:
        return "block_invalid_metadata"
    if not observation["parties_valid"]:
        return "block_invalid_structure"
    if not observation["items_valid"]:
        return "block_invalid_items"
    if not observation["totals_present"]:
        return "calculate_totals"
    if not observation["review_present"]:
        return "prepare_review_package"
    if not observation["send_requested"]:
        return "wait_for_human_review"
    if state.sending_requires_approval and not observation["approved"]:
        return "wait_for_approval"
    return "simulate_send"


def execute_action(
    state: AgentState,
    action: str,
    observation: dict[str, Any],
) -> dict[str, Any]:
    if action == "request_missing_information":
        state.status = Status.WAITING_FOR_INPUT
        state.stop_reason = "lipsesc câmpuri obligatorii din factură"
        return {"ok": False, "missing_fields": observation["missing_fields"]}

    if action == "block_invalid_metadata":
        state.status = Status.BLOCKED
        state.stop_reason = "metadatele facturii au o structură invalidă"
        return {"ok": False, "errors": observation["metadata_errors"]}

    if action == "block_invalid_structure":
        state.status = Status.BLOCKED
        state.stop_reason = "structura vânzătorului sau a clientului este invalidă"
        return {"ok": False, "errors": observation["party_errors"]}

    if action == "block_invalid_items":
        state.status = Status.BLOCKED
        state.stop_reason = "pozițiile facturii sunt invalide"
        return {"ok": False, "errors": observation["item_errors"]}

    if action == "calculate_totals":
        return calculate_totals(state)

    if action == "prepare_review_package":
        return prepare_review_package(state)

    if action == "wait_for_human_review":
        state.status = Status.WAITING_FOR_REVIEW
        state.stop_reason = "pachetul este pregătit și așteaptă verificarea unui om"
        return {"ok": True, "waiting_for": "human_review"}

    if action == "wait_for_approval":
        state.status = Status.WAITING_FOR_APPROVAL
        state.stop_reason = "este necesară aprobarea explicită înainte de trimiterea simulată"
        return {"ok": True, "waiting_for": "approval"}

    if action == "simulate_send":
        return simulate_send(state)

    state.status = Status.FAILED
    state.stop_reason = f"acțiune necunoscută: {action}"
    return {"ok": False, "error": state.stop_reason}


def run_agent(state: AgentState) -> AgentState:
    while state.status == Status.RUNNING:
        if state.step_count >= state.max_steps:
            state.status = Status.FAILED
            state.stop_reason = "a fost atins numărul maxim de pași"
            break

        observation = observe(state)
        action = choose_action(state, observation)
        state.step_count += 1
        state.actions_taken.append(action)
        result = execute_action(state, action, observation)
        state.trace.append(
            TraceEntry(
                step=state.step_count,
                observation=observation,
                selected_action=action,
                result=result,
                status_after_action=state.status.value,
            )
        )
    return state


def serializable_state(state: AgentState) -> dict[str, Any]:
    data = asdict(state)
    data["tax_rate"] = str(state.tax_rate)
    data["status"] = state.status.value
    return data


def write_trace(state: AgentState, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as handle:
        for entry in state.trace:
            handle.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")


def build_state(
    config: dict[str, Any],
    invoice: dict[str, Any],
    *,
    send_requested: bool = False,
    approved: bool = False,
) -> AgentState:
    goal_raw = config.get("goal")
    required_fields_raw = config.get("required_fields")
    tax_rate_raw = config.get("tax_rate")
    max_steps_raw = config.get("max_steps")
    approval_raw = config.get("sending_requires_approval")

    if not isinstance(goal_raw, str) or not goal_raw.strip():
        raise ValueError("goal trebuie să fie un text nevid")
    if not isinstance(required_fields_raw, list) or not required_fields_raw or not all(
        isinstance(name, str) and name.strip() for name in required_fields_raw
    ):
        raise ValueError("required_fields trebuie să fie o listă nevidă de texte")
    if not isinstance(max_steps_raw, int) or isinstance(max_steps_raw, bool):
        raise ValueError("max_steps trebuie să fie un număr întreg")
    if max_steps_raw <= 0:
        raise ValueError("max_steps trebuie să fie mai mare decât zero")
    if not isinstance(approval_raw, bool):
        raise ValueError("sending_requires_approval trebuie să fie true sau false în JSON")
    if not isinstance(send_requested, bool) or not isinstance(approved, bool):
        raise ValueError("stările request/approval trebuie să fie booleene")

    try:
        tax_rate = Decimal(str(tax_rate_raw))
    except (InvalidOperation, TypeError, ValueError) as exc:
        raise ValueError("tax_rate nu este un număr valid") from exc
    if not tax_rate.is_finite() or tax_rate < 0 or tax_rate > 1:
        raise ValueError("tax_rate trebuie să fie un număr finit între 0 și 1")

    source_invoice = copy.deepcopy(invoice)
    ignored_fields: list[str] = []
    for untrusted_control_or_derived_field in (
        "calculated_totals",
        "simulated_send",
        "simulated_send_record",
        "send_requested",
        "approved",
    ):
        if untrusted_control_or_derived_field in source_invoice:
            source_invoice.pop(untrusted_control_or_derived_field)
            ignored_fields.append(untrusted_control_or_derived_field)

    return AgentState(
        goal=goal_raw,
        invoice=source_invoice,
        required_fields=list(required_fields_raw),
        tax_rate=tax_rate,
        max_steps=max_steps_raw,
        sending_requires_approval=approval_raw,
        send_requested=send_requested,
        approved=approved,
        ignored_input_fields=ignored_fields,
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Rulează simulatorul agentului de verificare a facturilor din Lecția 2."
    )
    parser.add_argument("--input", required=True, type=Path, help="Fișierul JSON al facturii")
    parser.add_argument(
        "--config",
        type=Path,
        default=Path("configs/invoice_agent.json"),
        help="Fișierul JSON de configurare a agentului",
    )
    parser.add_argument(
        "--trace-file",
        type=Path,
        default=Path("logs/lesson02/latest-trace.jsonl"),
        help="Fișierul JSONL în care se scrie urma de execuție",
    )
    parser.add_argument(
        "--request-send",
        action="store_true",
        help="Solicită explicit pasul de trimitere simulată după verificare",
    )
    parser.add_argument(
        "--approve-send",
        action="store_true",
        help="Aprobă explicit trimiterea simulată; se folosește împreună cu --request-send",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if args.approve_send and not args.request_send:
        print(
            json.dumps(
                {
                    "status": "failed",
                    "error": "--approve-send are sens numai împreună cu --request-send",
                },
                indent=2,
                ensure_ascii=False,
            )
        )
        return 2

    try:
        config = load_json(args.config)
        invoice = load_json(args.input)
        final_state = run_agent(
            build_state(
                config,
                invoice,
                send_requested=args.request_send,
                approved=args.approve_send,
            )
        )
        write_trace(final_state, args.trace_file)
    except (ValueError, RuntimeError) as exc:
        print(
            json.dumps(
                {"status": "failed", "error": str(exc)},
                indent=2,
                ensure_ascii=False,
            )
        )
        return 2

    print(json.dumps(serializable_state(final_state), indent=2, ensure_ascii=False))
    return 0 if final_state.status != Status.FAILED else 1


if __name__ == "__main__":
    raise SystemExit(main())

Înainte să rulezi agentul, verifică dacă fișierul este sintactic valid:

python -m py_compile src/lesson02_invoice_agent.py

Dacă această comandă nu afișează nimic, compilarea sintactică a reușit. Apoi fă scriptul executabil și cere pagina de ajutor:

chmod +x src/lesson02_invoice_agent.py
./src/lesson02_invoice_agent.py --help

chmod +x acordă permisiunea de executare. Prima linie a fișierului, #!/usr/bin/env python3, este shebang-ul și îi spune Linux-ului ce interpretor să folosească atunci când rulezi fișierul direct.

Ce dovedește --help: nu dovedește că logica agentului este corectă, dar confirmă că fișierul poate fi importat și pornit, iar argumentele liniei de comandă au fost construite fără eroare.

10. Rulează scenariile și interpretează traseul agentului

Nu urmărim numai starea finală. Pentru fiecare scenariu verificăm ce acțiuni a ales agentul și de ce. Aceasta este diferența dintre „programul a dat un rezultat” și „înțeleg traseul prin care a ajuns la rezultat”.

Cum citești comenzile de mai jos

10.1 Factura completă: agentul pregătește, dar nu trimite

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_complete.json \
  --trace-file logs/lesson02/complete.jsonl \
  | jq '{status, actions_taken, calculated_totals, stop_reason}'

Ce trebuie să vezi: status este waiting_for_review, iar acțiunile sunt calculate_totals → prepare_review_package → wait_for_human_review. Totalul este 968.00.

Ce demonstrează: faptul că datele sunt valide nu înseamnă că agentul primește automat autoritatea de a trimite ceva. Partea automată se încheie în punctul în care este necesară verificarea umană.

10.2 Client lipsă: lipsa datelor are prioritate

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_missing.json \
  --trace-file logs/lesson02/missing.jsonl \
  | jq '{status, missing_fields, actions_taken, stop_reason}'

Ce trebuie să vezi: waiting_for_input, iar customer apare în missing_fields. calculate_totals nu trebuie să apară.

Ce demonstrează: agentul verifică dacă problema este suficient de bine definită înainte să consume pași pentru progres. Nu inventează informația lipsă.

10.3 Document adversarial: rezultatele și aprobarea injectate sunt ignorate

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_untrusted_totals.json \
  --trace-file logs/lesson02/untrusted.jsonl \
  | jq '{status, actions_taken, calculated_totals, ignored_input_fields}'

Ce trebuie să vezi: agentul recalculează totalul la 968.00, iar calculated_totals, send_requested și approved apar în ignored_input_fields.

Ce demonstrează: programul separă datele pe care le observă de rezultatele și permisiunile pe care numai agentul sau un canal extern de control le pot produce.

10.4 Structură malformată: eroarea devine stare controlată

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_malformed_party.json \
  --trace-file logs/lesson02/malformed.jsonl \
  | jq '{status, actions_taken, validation_errors, stop_reason}'

Ce trebuie să vezi: blocked și o explicație despre structura lui seller, fără traceback.

Ce demonstrează: o valoare greșită din datele primite nu trebuie să provoace automat prăbușirea programului. Agentul trebuie să recunoască situația și să ajungă într-o stare controlată.

10.5 Trimitere solicitată, dar neaprobată

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_approved.json \
  --request-send \
  --trace-file logs/lesson02/requested.jsonl \
  | jq '{status, actions_taken, stop_reason}'

Ce trebuie să vezi: waiting_for_approval. Agentul calculează și pregătește pachetul, dar acțiunea simulate_send nu apare.

Ce demonstrează: intenția de a face o acțiune nu este aceeași cu permisiunea de a o executa.

10.6 Trimitere solicitată și aprobată

./src/lesson02_invoice_agent.py \
  --input data/inbox/invoice_approved.json \
  --request-send \
  --approve-send \
  --trace-file logs/lesson02/approved.jsonl \
  | jq '{status, actions_taken, calculated_totals, simulated_send_record, stop_reason}'

Ce trebuie să vezi: completed, acțiunea simulate_send și totalul 726.00. simulated_send_record înregistrează acțiunea simulată.

Ce demonstrează: aceeași capacitate poate fi blocată sau permisă în funcție de autoritate. Chiar și aici nu există efect extern real; programul scrie doar o înregistrare în propria stare.

10.7 Citește jurnalul execuției, nu doar rezultatul final

jq '{step, action: .selected_action, status: .status_after_action}' \
  logs/lesson02/complete.jsonl

Fiecare linie JSONL reprezintă un pas complet. Uită-te la ordinea acțiunilor și compar-o cu schema din Secțiunea 8. Jurnalul este dovada că agentul a trecut printr-o succesiune de observații și decizii, nu a „sărit” direct la rezultatul final.

11. Testează regulile, nu doar exemplele

Rulările manuale ne ajută să înțelegem comportamentul, dar nu sunt suficiente pentru întreținerea proiectului. Testele automate transformă regulile importante în verificări care pot fi repetate după fiecare modificare.

Cum se citește un test

Majoritatea testelor urmează trei pași: pregătește o situație cunoscută, rulează funcția sau agentul, apoi compară rezultatul cu ceea ce trebuia să se întâmple. În engleză vei întâlni frecvent formula Arrange → Act → Assert.

De exemplu, testul pentru client lipsă șterge customer, rulează agentul și verifică două lucruri: starea trebuie să fie WAITING_FOR_INPUT, iar calculate_totals nu trebuie să fi fost executat.

Suita de mai jos verifică atât traseele normale, cât și defecte care ar putea deveni vulnerabilități: aprobare falsă introdusă ca text, valori NaN, limită de pași și totaluri injectate de document.

from __future__ import annotations

import copy
import sys
import unittest
from decimal import Decimal
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from lesson02_invoice_agent import (  # noqa: E402
    AgentState,
    Status,
    build_state,
    run_agent,
)

RAW_CONFIG = {
    "goal": "prepare_invoice_for_human_review",
    "required_fields": ["seller", "customer", "invoice_number", "currency", "items"],
    "tax_rate": "0.21",
    "max_steps": 12,
    "sending_requires_approval": True,
}

COMPLETE_INVOICE = {
    "seller": {"name": "Furnizor", "tax_id": "RO1"},
    "customer": {"name": "Client", "tax_id": "RO2"},
    "invoice_number": "TEST-1",
    "currency": "RON",
    "items": [
        {"description": "Serviciu", "quantity": "2", "unit_price": "100.00"}
    ],
}


def make_state(invoice: dict, **overrides) -> AgentState:
    values = {
        "goal": RAW_CONFIG["goal"],
        "required_fields": list(RAW_CONFIG["required_fields"]),
        "tax_rate": Decimal(RAW_CONFIG["tax_rate"]),
        "max_steps": RAW_CONFIG["max_steps"],
        "sending_requires_approval": RAW_CONFIG["sending_requires_approval"],
    }
    values.update(overrides)
    return AgentState(invoice=copy.deepcopy(invoice), **values)


class InvoiceAgentTests(unittest.TestCase):
    def test_complete_invoice_waits_for_human_review(self) -> None:
        state = run_agent(make_state(COMPLETE_INVOICE))
        self.assertEqual(state.status, Status.WAITING_FOR_REVIEW)
        self.assertIsNotNone(state.review_package)
        self.assertEqual(state.calculated_totals["total"], "242.00")
        self.assertNotIn("calculated_totals", state.invoice)

    def test_missing_customer_waits_for_input(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        del invoice["customer"]
        state = run_agent(make_state(invoice))
        self.assertEqual(state.status, Status.WAITING_FOR_INPUT)
        self.assertIn("customer", state.missing_fields)
        self.assertNotIn("calculate_totals", state.actions_taken)

    def test_invalid_negative_price_is_blocked(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        invoice["items"][0]["unit_price"] = "-1"
        state = run_agent(make_state(invoice))
        self.assertEqual(state.status, Status.BLOCKED)
        self.assertEqual(state.actions_taken, ["block_invalid_items"])

    def test_non_finite_quantity_is_blocked(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        invoice["items"][0]["quantity"] = "NaN"
        state = run_agent(make_state(invoice))
        self.assertEqual(state.status, Status.BLOCKED)
        self.assertIn("număr finit", " ".join(state.validation_errors))

    def test_malformed_seller_blocks_instead_of_crashing(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        invoice["seller"] = "Furnizor introdus ca text"
        state = run_agent(make_state(invoice))
        self.assertEqual(state.status, Status.BLOCKED)
        self.assertEqual(state.actions_taken, ["block_invalid_structure"])
        self.assertIn("seller trebuie să fie un obiect JSON", state.validation_errors)

    def test_send_request_without_approval_waits(self) -> None:
        state = run_agent(make_state(COMPLETE_INVOICE, send_requested=True, approved=False))
        self.assertEqual(state.status, Status.WAITING_FOR_APPROVAL)
        self.assertNotIn("simulate_send", state.actions_taken)

    def test_approved_send_is_only_simulated(self) -> None:
        state = run_agent(make_state(COMPLETE_INVOICE, send_requested=True, approved=True))
        self.assertEqual(state.status, Status.COMPLETED)
        self.assertIn("simulate_send", state.actions_taken)
        self.assertTrue(state.simulated_send_record["performed"])
        self.assertNotIn("simulated_send", state.invoice)

    def test_step_limit_stops_without_exceeding_limit(self) -> None:
        state = run_agent(make_state(COMPLETE_INVOICE, max_steps=1))
        self.assertEqual(state.status, Status.FAILED)
        self.assertEqual(state.step_count, 1)
        self.assertEqual(state.stop_reason, "a fost atins numărul maxim de pași")

    def test_missing_data_has_priority_over_progress_actions(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        del invoice["customer"]
        state = run_agent(make_state(invoice, send_requested=True, approved=True))
        self.assertEqual(state.actions_taken, ["request_missing_information"])
        self.assertEqual(state.status, Status.WAITING_FOR_INPUT)

    def test_supplied_totals_and_approval_fields_are_ignored(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        invoice["calculated_totals"] = {
            "subtotal": "1.00",
            "tax": "0.21",
            "total": "1.21",
        }
        invoice["send_requested"] = True
        invoice["approved"] = True
        state = run_agent(build_state(RAW_CONFIG, invoice))
        self.assertIn("calculate_totals", state.actions_taken)
        self.assertNotIn("simulate_send", state.actions_taken)
        self.assertEqual(state.calculated_totals["total"], "242.00")
        self.assertEqual(state.status, Status.WAITING_FOR_REVIEW)
        self.assertIn("calculated_totals", state.ignored_input_fields)
        self.assertIn("send_requested", state.ignored_input_fields)
        self.assertIn("approved", state.ignored_input_fields)

    def test_rounding_keeps_displayed_totals_consistent(self) -> None:
        invoice = copy.deepcopy(COMPLETE_INVOICE)
        invoice["items"] = [
            {"description": "Fractional service", "quantity": "0.333", "unit_price": "1.00"}
        ]
        state = run_agent(make_state(invoice))
        self.assertEqual(state.calculated_totals["subtotal"], "0.33")
        self.assertEqual(state.calculated_totals["tax"], "0.07")
        self.assertEqual(state.calculated_totals["total"], "0.40")

    def test_build_state_rejects_non_positive_max_steps(self) -> None:
        config = {**RAW_CONFIG, "max_steps": 0}
        with self.assertRaises(ValueError):
            build_state(config, COMPLETE_INVOICE)

    def test_build_state_rejects_negative_tax_rate(self) -> None:
        config = {**RAW_CONFIG, "tax_rate": "-0.01"}
        with self.assertRaises(ValueError):
            build_state(config, COMPLETE_INVOICE)

    def test_build_state_rejects_string_boolean_in_config(self) -> None:
        config = {**RAW_CONFIG, "sending_requires_approval": "false"}
        with self.assertRaises(ValueError):
            build_state(config, COMPLETE_INVOICE)

    def test_review_package_reflects_approval_rule(self) -> None:
        state = run_agent(make_state(COMPLETE_INVOICE, sending_requires_approval=False))
        self.assertFalse(state.review_package["approval_required_for_send"])


if __name__ == "__main__":
    unittest.main()

Salvează codul în tests/test_lesson02_invoice_agent.py. Verifică mai întâi sintaxa, apoi rulează suita:

python -m py_compile tests/test_lesson02_invoice_agent.py
python -m unittest -v tests/test_lesson02_invoice_agent.py

Rezultatul corect este 15 teste trecute și linia finală OK. Dacă un test eșuează, citește numele lui înainte de traceback: numele spune ce regulă nu mai este respectată.

De ce există teste pentru lucruri care „par imposibile”?

Pentru că datele reale nu respectă întotdeauna intențiile programatorului. JSON-ul poate conține "false" ca text în loc de false ca valoare booleană; un număr poate deveni NaN; o modificare viitoare poate schimba ordinea regulilor. Testele fixează explicit comportamentul dorit și fac aceste presupuneri vizibile.

Ce vulnerabilitate previne testul totalurilor furnizate de document?

Previne situația în care factura introduce propriul câmp calculated_totals, iar mecanismul de control consideră în mod greșit că verificarea a fost deja executată. Versiunea corectă elimină acel câmp din copia sursei și recalculează totalurile în starea agentului.

12. Desenează aceeași buclă cu Graphviz

Până aici ai văzut bucla în cod și în jurnal. Acum o reprezentăm vizual. Scopul nu este decorativ: dacă diagrama și codul descriu procese diferite, documentația nu mai corespunde implementării.

Cum citești un fișier DOT

digraph începe un graf orientat. Linii precum goal -> observe creează săgeți. Nodul boundary are formă de romb deoarece reprezintă o decizie: agentul continuă, așteaptă un om sau se oprește.

digraph InvoiceAgent {
  rankdir=LR;
  graph [fontname="DejaVu Sans", bgcolor="white", pad="0.35", nodesep="0.45", ranksep="0.7"];
  node [shape=box, style="rounded,filled", fillcolor="#f5f2ea", color="#174a45", fontcolor="#173632", fontname="DejaVu Sans", penwidth=1.4, margin="0.14,0.10"];
  edge [fontname="DejaVu Sans", color="#46645f", fontcolor="#284843", penwidth=1.2];

  goal [label="Obiectiv\nPregătește factura pentru verificare"];
  observe [label="Observă\nCâmpuri lipsă, structură, stare"];
  decide [label="Decide\nAlege o singură acțiune permisă"];
  act [label="Acționează\nValidează, calculează, pregătește sau cere date"];
  evaluate [label="Evaluează\nÎnregistrează rezultatul și noua stare"];
  boundary [shape=diamond, fillcolor="#efe4c7", label="Continuă?"];
  wait [label="Așteaptă\nDate, verificare sau aprobare"];
  stop [label="Oprire\nFinalizat, blocat sau eșuat"];

  goal -> observe;
  observe -> decide;
  decide -> act;
  act -> evaluate;
  evaluate -> boundary;
  boundary -> observe [label="da"];
  boundary -> wait [label="este nevoie de om"];
  boundary -> stop [label="nu"];
}

Salvează textul în diagrams/lesson02_invoice_agent.dot, apoi generează imaginea:

dot -Tpng -Gdpi=160   diagrams/lesson02_invoice_agent.dot   -o diagrams/lesson02_invoice_agent.png

xdg-open diagrams/lesson02_invoice_agent.png

Exercițiu de verificare imediată: compară imaginea cu logs/lesson02/complete.jsonl. Pentru factura completă, jurnalul trebuie să urmeze o ramură posibilă din diagramă și să se termine în așteptarea verificării umane.

Ce nu este diagrama: fișierul DOT nu controlează programul. Dacă modifici doar diagrama, comportamentul Python nu se schimbă. Este documentație executabilă doar în sensul că Graphviz o poate transforma într-o imagine.

13. Proiectează bucla propriului agent

Exemplul facturii este doar un caz. Acum transferi aceeași structură către agentul pe care vrei să-l construiești. Nu alege încă un model sau un cadru software (framework); mai întâi definește problema, mediul, acțiunile și limitele.

Completează fișa în docs/lessons/lesson-02/agent-loop-design.md. Fiecare secțiune răspunde la o întrebare necesară pentru arhitectură:

cat > docs/lessons/lesson-02/agent-loop-design.md <<'EOF'
# Lecția 2 — Proiectarea buclei agentului

## Nume de lucru

## Utilizatorul pentru care este construit

## Obiectiv

## Dovada că obiectivul a fost atins

## Mediul în care operează
Enumeră sistemele externe, sursele de fișiere, aplicațiile, persoanele sau dispozitivele relevante.

## Observații disponibile
Enumeră informațiile pe care agentul le poate primi. Separă datele observate direct de interpretările făcute asupra lor.

## Starea care trebuie păstrată
Enumeră informațiile necesare între pași. Separă datele-sursă nesigure de rezultatele calculate de agent.

## Acțiuni permise

## Acțiuni care necesită aprobare

## Acțiuni interzise

## Instrumente
Pentru fiecare instrument, notează intrările, ieșirile, efectele externe posibile, erorile și permisiunile necesare.

## Cum este aleasă acțiunea următoare
Reguli, mașină de stări, căutare, planificator, model sau combinație.

## Condiții de așteptare

## Condiții de escaladare către un om

## Condiții de finalizare

## Condiții de eșec

## Limite de resurse
Număr maxim de pași, reîncercări, timp, cost și apeluri de instrumente.

## Informații pentru audit
Ce trebuie înregistrat pentru ca o altă persoană să poată reconstrui rularea?
EOF
Exemplu scurt: cum ai completa „Dovada că obiectivul a fost atins”?

Dacă obiectivul ar fi „pregătește o factură pentru verificarea contabilului”, o dovadă bună ar fi: „există un pachet de verificare care conține părțile, pozițiile validate, totalurile recalculate și lista problemelor rămase, iar starea este waiting_for_review”. Formularea este mai bună decât „agentul a terminat”, deoarece poate fi verificată din date.

14. Înregistrează decizia de arhitectură — ADR-0002

În Laboratorul 1 ai introdus primul ADR. Continuăm aceeași practică. Un Architecture Decision Record (ADR) nu descrie tot proiectul; păstrează o singură decizie importantă împreună cu motivul și consecințele ei.

Decizia acestei lecții este: starea activă a agentului trebuie să fie explicită și inspectabilă, iar rezultatele derivate trebuie separate de documentul-sursă. Creează fișierul:

cat > docs/architecture-decisions/ADR-0002-explicit-agent-state.md <<'EOF'
# ADR-0002: Păstrăm starea agentului explicită și inspectabilă

## Stare
Acceptată pentru proiectul cumulativ al cursului.

## Context
Un agent care lucrează în mai mulți pași trebuie să păstreze obiectivul, observațiile, acțiunile, rezultatele instrumentelor, rezultatele validării, erorile, aprobările și motivul opririi. Dacă aceste informații rămân ascunse, erorile sunt greu de investigat, iar evaluarea devine nesigură.

Documentul analizat este o sursă externă de date și nu trebuie să poată pretinde că valorile derivate au fost deja calculate ori verificate și nici că o acțiune a fost aprobată.

## Decizie
Reprezentăm starea sarcinii active prin date structurate și stări explicite. Păstrăm totalurile calculate, rezultatele validării, pachetul pentru verificare, cererea de trimitere, aprobarea și acțiunile simulate separat de factura-sursă. Înregistrăm cronologic observațiile, acțiunile selectate și rezultatele.

## Consecințe
- Fiecare rulare poate fi inspectată și testată.
- Datele de intrare nu pot sări peste verificările agentului și nu se pot autoaproba.
- Sarcinile întrerupte pot fi reluate ulterior în mod deliberat.
- Vor fi necesare reguli suplimentare pentru stocare, identitate și protecția datelor.
- Structura stării trebuie modificată cu grijă pe măsură ce proiectul crește.
EOF

Observă structura: Context explică problema; Decizie spune ce alegem; Consecințe notează atât avantajele, cât și costurile. Un ADR bun nu încearcă să pretindă că alegerea nu are dezavantaje.

15. Verifică și salvează modificările cu Git

Nu facem commit imediat după ce am creat fișierele. Mai întâi verificăm ce vede Git și confirmăm că jurnalele de execuție rămân ignorate.

Dacă nu ai configurat încă identitatea Git pe acest calculator:

git config --global user.name "Numele tău"
git config --global user.email "email@example.com"

Pentru un repository public poți folosi o adresă de tip no-reply, dacă nu dorești să îți expui adresa personală.

Verificările înainte de commit

git status --short
git check-ignore -v logs/lesson02/complete.jsonl

git add \
  .gitignore \
  configs/invoice_agent.json \
  data/inbox/ \
  src/lesson02_invoice_agent.py \
  tests/test_lesson02_invoice_agent.py \
  diagrams/lesson02_invoice_agent.dot \
  diagrams/lesson02_invoice_agent.png \
  docs/lessons/lesson-02/agent-loop-design.md \
  docs/architecture-decisions/ADR-0002-explicit-agent-state.md

git diff --cached --stat
git commit -m "Build explicit Lesson 2 agent loop"

Ce trebuie să verifici înainte să confirmi commit-ul: fișierele din logs/lesson02/ nu trebuie să apară printre fișierele adăugate. Dacă apar, oprește-te și verifică .gitignore.

16. Exerciții practice — de la înțelegere la modificare

Exercițiile cresc gradual în dificultate. Fă primele trei înainte să consideri laboratorul terminat. Fiecare exercițiu cere o singură schimbare principală și are un indiciu sau un răspuns posibil ascuns.

16.1 Exercițiu de bază: prezice înainte să rulezi

Creează o copie a facturii complete în care unit_price este -1. Înainte de rulare, scrie pe o linie ce stare și ce primă acțiune te aștepți să vezi. Apoi rulează agentul și compară predicția cu rezultatul.

Răspuns posibil

Agentul trebuie să ajungă în blocked. Prima și singura acțiune de oprire trebuie să fie block_invalid_items, deoarece validarea liniilor facturii se face înainte de calculul totalurilor. Un preț negativ nu trebuie „reparat” automat de agent.

16.2 Exercițiu de validare: limitează monedele acceptate

Modifică programul astfel încât currency să accepte numai RON și EUR. Adaugă un singur test cu USD și verifică faptul că agentul se blochează înainte de calculate_totals.

Indiciu

Regula aparține validării informațiilor generale ale facturii — aici, moneda — nu funcției de calcul. Adaugă eroarea în observație și lasă mecanismul de decizie existent să transforme eroarea într-o acțiune de blocare.

16.3 Exercițiu de modelare a stării: „verificat” nu înseamnă „aprobat”

Adaugă în AgentState un câmp separat care arată dacă un om a verificat pachetul. Nu modifica logica argumentului --approve-send. Scopul este să poți reprezenta simultan situația „pachet verificat, dar trimiterea încă neaprobată”.

De ce contează separarea?

Verificarea răspunde la întrebarea „este rezultatul corect?”, iar aprobarea răspunde la întrebarea „are voie sistemul să execute această acțiune?”. Sunt decizii diferite și pot aparține unor persoane sau roluri diferite.

16.4 Exercițiu de siguranță: strică intenționat ordinea regulilor

Într-o copie temporară a funcției choose_action(), mută verificarea aprobării înaintea verificării datelor lipsă. Rulează testul pentru clientul lipsă și observă ce comportament se schimbă. Apoi restaurează imediat ordinea corectă.

Ce trebuie să înțelegi

Ordinea regulilor influențează direct comportamentul agentului. Dacă agentul verifică aprobarea înainte să observe că lipsesc date esențiale, poate ajunge prea devreme într-o etapă pentru care informațiile necesare nu există încă. De aceea, verificarea datelor și regulile de siguranță trebuie evaluate înaintea pașilor care încearcă să avanseze sarcina.

16.5 Extensie: o singură reîncercare controlată

Simulează o eroare temporară a instrumentului de calcul: prima apelare eșuează, a doua reușește. Permite maximum o reîncercare și scrie un test care confirmă că agentul se oprește sigur dacă și a doua încercare eșuează.

Indiciu de proiectare

Nu folosi o buclă infinită. Păstrează în stare numărul de încercări și include limita în decizie. Astfel, jurnalul poate explica de ce s-a repetat acțiunea și de ce s-a oprit.

16.6 Extensie de documentație

Adaugă în diagrama DOT noduri pentru Configurație, AgentState, Jurnal și Aprobare umană. Generează din nou imaginea PNG și verifică dacă săgețile descriu relațiile reale din cod.

17. Depanare: pornește de la simptom

Când ceva nu merge, nu repeta toate comenzile și nu adăuga sudo la întâmplare. Citește mesajul complet, verifică folderul curent și identifică etapa la care ai ajuns.

cd: No such file or directory

Folderul nu există sau ai tastat altă cale. Rulează ls -la ~, apoi mkdir -p ~/ai-agents-zero-to-expert și încearcă din nou.

.venv/bin/activate lipsește

Mediul virtual nu a fost creat în folderul curent. Confirmă cu pwd, apoi rulează python3 -m venv .venv și source .venv/bin/activate.

Permission denied când rulezi scriptul

Fișierul nu are permisiunea de executare. Rulează chmod +x src/lesson02_invoice_agent.py. Alternativ, îl poți porni întotdeauna cu python src/lesson02_invoice_agent.py --help.

BrokenPipeError la o comandă care folosește | jq ...

Mesajul apare atunci când programul din dreapta conductei (pipe) se închide înainte ca Python să termine de scris rezultatul. În acest laborator, cauza cea mai frecventă este că jq nu este instalat sau nu poate porni. Verifică prin jq --version. Dacă primești command not found, reia Pasul 2 și instalează jq. Dacă jq există, citește și mesajul lui de eroare: o expresie jq invalidă poate închide conducta în același mod.

jq raportează eroare JSON

Rulează jq . nume-fisier.json. Verifică mai întâi virgulele, ghilimelele duble și acoladele. Dacă jq acceptă fișierul, problema este probabil în structura de date pe care o așteaptă agentul, nu în sintaxa JSON.

dot: command not found

Graphviz nu este instalat sau terminalul nu îl găsește. Rulează sudo apt install -y graphviz, apoi verifică prin dot -V.

--approve-send este respins

Aprobarea nu are sens dacă nu există o cerere de trimitere. Folosește împreună --request-send --approve-send.

Un test eșuează, dar programul „pare” să funcționeze

Nu ignora testul. Rulează numai fișierul de teste cu -v, citește numele verificării care a eșuat și compară regula testată cu modificarea ta. Un rezultat manual corect într-un scenariu nu garantează că toate celelalte reguli au rămas corecte.

18. Checklist de finalizare

Bifează un punct numai după ce ai rulat comanda sau ai verificat comportamentul descris. Lista confirmă că proiectul poate fi reluat în lecția următoare fără pași lipsă.

19. Ce ai construit de fapt

La final nu ai doar un script care calculează o factură. Ai un exemplu mic de agent determinist cu stare explicită și buclă de control: urmărește un obiectiv, observă situația, selectează acțiuni în funcție de stare, primește rezultatul acțiunii și repetă procesul până ajunge într-o condiție de oprire.

Nu este încă un agent bazat pe LLM și nu este autonom în sens larg. Tocmai acesta este avantajul didactic: poți vedea unde se află controlul înainte să introducem un model probabilistic. În lecțiile următoare vom putea înlocui sau completa anumite decizii cu modele fără să pierdem regulile deterministe, aprobările, jurnalul și testele.

Rezultatul Lecției 2: acum poți arăta în cod diferența dintre model, instrument, stare, mecanism de control, aprobare și buclă de control a agentului — chiar dacă agentul nu folosește încă un LLM.