# Resume Agent MVP backend An intentionally small FastAPI service for building a first usable resume through an explicit finite-state machine. State is persisted in SQLite; there is no LangChain or LangGraph dependency. ## Run locally Python 3.11 or newer is required. ```powershell cd F:\offerpai_web\resume-agent-mvp\backend python -m pip install -r requirements.txt Copy-Item .env.example .env # Leave OPENAI_API_KEY empty for offline rules, or configure the live LLM values below. python -m uvicorn app.main:app --reload --port 8000 ``` The default database is `data/resume_agent.db`. Override it with `RESUME_AGENT_DATABASE`. CORS defaults to `http://localhost:5173` and `http://127.0.0.1:5173`; set a comma-separated `RESUME_AGENT_CORS_ORIGINS` to change it. OpenAPI is available at `http://localhost:8000/docs` and the health check at `GET /health`. ## Public API All workflow routes use `/ai-api/resume-agent`: | Method | Path | Purpose | | --- | --- | --- | | `POST` | `/sessions` | Start a session; body is optional and may contain `account_phone` and `metadata` | | `GET` | `/sessions/{session_id}/timeline` | Return the session and ordered conversation turns | | `POST` | `/sessions/{session_id}/component-events` | Apply an event to one active component | | `POST` | `/sessions/{session_id}/messages` | Describe the first anchor or add enrichment text | | `POST` | `/sessions/{session_id}/create` | Idempotently create the business resume | | `DELETE` | `/sessions/{session_id}` | Delete a session and its related data | Component events have one uniform shape: ```json { "component_id": "block_...", "event": "submit", "payload": {"field": "school", "value": "示例大学"} } ``` Canonical actions are `accept_privacy`, `decline_privacy`, `use_account_phone`, `use_other_phone`, `submit_manual_phone`, `submit_name`, `select_job_type`, `select_anchor_type`, `submit_field`, `submit_date_range`, `select_choice`, `confirm_anchor`, `edit_anchor`, `continue_enriching`, and `finish_enrichment`. Generic UI actions (`accept`, `consent`, `select`, `submit`, `confirm`, `edit`) are normalized according to the active component. `POST /create` accepts an optional `idempotency_key`. Creation is idempotent by session, so retries return the existing `resume_id` with `created: false`, even if a different key is sent. ## Workflow and gates The core order is: ```text PRIVACY_CONSENT -> PHONE_SELECTION -> MANUAL_PHONE_INPUT (only when selected) -> NAME_CAPTURE -> JOB_TYPE_SELECT -> ANCHOR_TYPE_SELECT (only for other/fallback) -> ANCHOR_COLLECTING -> ANCHOR_CONFIRM -> MINIMUM_READY -> RESUME_CREATING -> RESUME_ENRICHING -> CONTENT_READY ``` The first anchor starts with an open chat prompt. Explicit facts are extracted from the user's description, and only the remaining structural gaps are rendered as inline components. `CONTENT_DISAMBIGUATION` asks for more detail when an enrichment message is too vague. `CREATE_FAILED` exposes a retry card if the replaceable writer fails. After the business resume exists, an AI rewrite is held as a proposed patch. The user must confirm the `ExperienceConfirmCard` before the resume revision is updated and `formal_content_ready` becomes true. First-anchor gates are exact: - Education: `school`, `major`, `degree`, `start_date`, `end_date_or_present` - Work or internship: `company`, `position`, `start_date`, `end_date_or_present` - Project: `project_name`, `project_role`, `start_date`, `end_date_or_present` Campus recruitment selects education automatically; social recruitment selects work experience; `other` asks the user to choose education, work, internship, or project. Manual phones must exactly match `^1[3-9]\d{9}$`. Responses expose only `masked_phone` and `phone_source`; the raw account/manual phone is not included in the timeline, blocks, draft, or resume response. ## Conversation protocol Every `ConversationTurn` contains ordered `ComponentBlock` objects. Block `type` is one of `text`, `component`, `resume_patch`, `status`, or `error`. Interactive blocks carry both: - `data.component`: stable full snake_case name such as `privacy_consent_card` - `data.component_name`: canonical UI name such as `PrivacyConsentCard` A handled block remains in the timeline with a read-only lifecycle such as `submitted` or `confirmed`. New events are accepted only for the current `active` block, preventing duplicate or stale transitions. ## OpenAI-compatible LLM `ExperienceExtractor` and `ResumeRewriter` remain vendor-neutral protocols. When `OPENAI_API_KEY` is non-empty, the default application uses the official OpenAI Python SDK against the configured compatible endpoint: ```dotenv RESUME_AGENT_LLM_PROVIDER=auto OPENAI_API_KEY=your-key OPENAI_BASE_URL=https://re.94xy.cn OPENAI_MODEL=your-gateway-model-id ``` The adapter is implemented in `app/llm_services.py` with the same SDK shape as: ```python from openai import OpenAI client = OpenAI( api_key=settings.openai_api_key, base_url=settings.openai_base_url, timeout=settings.openai_timeout_seconds, max_retries=settings.openai_max_retries, ) response = client.chat.completions.create( model=settings.openai_model, messages=messages, response_format=response_format, ) ``` The configured base URL is passed directly to the SDK. Do not add `/chat/completions`; add `/v1` only if the gateway's documentation requires it. The adapter calls `chat.completions.create` with JSON Schema structured output and then validates every response with Pydantic. Set `OPENAI_STRUCTURED_OUTPUT_MODE=json_object` only when a compatible gateway does not support `json_schema`. SDK transport behavior is controlled by `OPENAI_TIMEOUT_SECONDS` and `OPENAI_MAX_RETRIES`; malformed structured responses use `OPENAI_STRUCTURED_OUTPUT_RETRIES`. `RESUME_AGENT_LLM_PROVIDER=rule` forces deterministic local extraction for tests or offline development. With `RESUME_AGENT_LLM_FALLBACK_TO_RULES=true`, an unavailable or invalid model response falls back to those deterministic services. Set it to `false` when upstream failures should surface as workflow errors. For a real SDK smoke test, use: ```dotenv RESUME_AGENT_LLM_PROVIDER=openai RESUME_AGENT_LLM_FALLBACK_TO_RULES=false ``` This prevents the rule fallback from making a failed gateway call look successful. Automated tests inject fake clients, so `pytest` does not send requests or consume model quota. Then execute `python scripts\smoke_llm.py`. It makes one extraction request with the configured SDK client, disables rule fallback for that request, and never prints the key. The LLM receives only an allow-listed facts DTO. Account/manual phone numbers, `account_phone`, session metadata, the user's name, and raw internal profile state are never sent to the model. Phone-like strings, email addresses, and labeled WeChat IDs typed into free text are redacted again at the final SDK boundary. The model cannot select a Stage, component, gate, or database action. For dependency injection tests, pass `settings=` and `openai_client=` to `create_app`, or pass explicit `extractor=` / `rewriter=` implementations. The FSM and API contract do not depend on the model vendor. ## Test ```powershell pytest -q ``` To verify only imports and configuration after installation: ```powershell python -c "from openai import OpenAI; from app.main import app; print(app.title)" ``` If the compatible gateway rejects `response_format.type=json_schema`, change `OPENAI_STRUCTURED_OUTPUT_MODE=json_object`. If it returns a model-not-found error, replace `OPENAI_MODEL` with the exact model ID supported by that gateway. Tests cover the full campus flow, social/other anchor gates, strict and private phone handling, component retries/lifecycle, idempotent resume creation, enrichment/disambiguation, CORS, and deletion.