Coverage for src/lilbee/server/models.py: 100%

730 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Request and response models for the lilbee HTTP API. 

2 

3Typed pydantic models so Litestar's OpenAPI schema has field-level detail. 

4""" 

5 

6from __future__ import annotations 

7 

8from typing import TYPE_CHECKING, Any, Literal 

9 

10from pydantic import BaseModel, Field, field_validator 

11 

12from lilbee.app.agent_configs.document import AgentClient, AgentSurface, ConfigFormat 

13from lilbee.app.models import ModelEntry 

14from lilbee.app.profiles import ( 

15 ActiveProfile, 

16 ApplyResult, 

17 ChangeRow, 

18 DiffRow, 

19 DiscardResult, 

20 ProfileDiff, 

21 ProfileEffect, 

22 ProfileLocation, 

23 ProfileStatus, 

24 SaveResult, 

25 credit_line, 

26) 

27from lilbee.app.settings import SettingInfo, SettingsUpdateResult 

28from lilbee.app.settings_map import SettingGroup 

29from lilbee.catalog.types import KeyStatus, ModelCompat, ModelSource, ModelTask 

30from lilbee.core.config.defaults import MAX_CRAWL_DEPTH, MAX_CRAWL_PAGES 

31from lilbee.core.config.enums import ( 

32 CrawlRenderMode, 

33 FtsLanguage, 

34 KvCacheType, 

35 OcrMode, 

36 SettingSource, 

37) 

38from lilbee.core.health_warnings import HealthWarning 

39from lilbee.core.profile_files import ProfileEntry, ProfileFolder, ProfileValidation 

40from lilbee.data.store import ChunkType, IndexMismatch, MemoryKind, scope_to_chunk_type 

41from lilbee.data.types import SkippedSource 

42from lilbee.providers.roles import EngineBackend, WorkerRole 

43from lilbee.runtime.hardware import FitLevel, SizeVariantInfo 

44from lilbee.runtime.progress import LibraryUpgradeEvent 

45from lilbee.sessions import MessageRole 

46from lilbee.wiki.entity_extractor import EntityKind 

47 

48if TYPE_CHECKING: 

49 from lilbee.app.agent_configs.detect import ClientDetection 

50 from lilbee.app.agent_configs.document import AgentConfigDocument 

51 from lilbee.app.analyze import ( 

52 AnalyzeReport, 

53 LanguageRow, 

54 Recommendation, 

55 SavedProfile, 

56 TipState, 

57 ) 

58 from lilbee.app.placement import PlacementView 

59 from lilbee.data.analyze import PdfSignals 

60 

61 

62def decode_chunk_type(value: str | None) -> ChunkType | None: 

63 """Decode a ``chunk_type`` string into a ``ChunkType`` at the HTTP boundary. 

64 

65 Delegates to the canonical :func:`scope_to_chunk_type` so query-param and 

66 request-body routes share one decoder: only ``"raw"`` or ``"wiki"`` filter 

67 the pool; everything else (including ``None`` and the UI-side ``"both"``) 

68 means no filter. Any other string raises ``ValueError`` with boundary- 

69 friendly guidance. 

70 """ 

71 try: 

72 return scope_to_chunk_type(value) 

73 except ValueError as exc: 

74 raise ValueError( 

75 f"chunk_type must be one of 'raw', 'wiki', 'both', or omitted; got {value!r}" 

76 ) from exc 

77 

78 

79class AskRequest(BaseModel): 

80 """Request body for /api/ask.""" 

81 

82 question: str 

83 top_k: int = Field(default=0, ge=0, le=100) 

84 options: dict[str, Any] | None = None 

85 chunk_type: ChunkType | None = None 

86 

87 @field_validator("chunk_type", mode="before") 

88 @classmethod 

89 def _check_chunk_type(cls, v: str | None) -> ChunkType | None: 

90 return decode_chunk_type(v) 

91 

92 

93class ChatRequest(BaseModel): 

94 """Request body for /api/chat.""" 

95 

96 question: str 

97 history: list[ChatMessage] = [] 

98 # None (unspecified) grounds with the configured top_k; an explicit 0 is a 

99 # pure-LLM call that skips retrieval entirely. 

100 top_k: int | None = Field(default=None, ge=0, le=100) 

101 options: dict[str, Any] | None = None 

102 chunk_type: ChunkType | None = None 

103 summary: str = "" 

104 """Carry-forward notes from earlier compactions, folded into the prompt.""" 

105 session_id: str | None = None 

106 """Session that receives the new summary when this turn compacts.""" 

107 

108 @field_validator("chunk_type", mode="before") 

109 @classmethod 

110 def _check_chunk_type(cls, v: str | None) -> ChunkType | None: 

111 return decode_chunk_type(v) 

112 

113 

114class _OcrRequest(BaseModel): 

115 """Per-request OCR fields shared by /api/sync and /api/add.""" 

116 

117 ocr: OcrMode | None = None 

118 ocr_timeout: float | None = None 

119 

120 

121class SyncRequest(_OcrRequest): 

122 """Request body for /api/sync. 

123 

124 ``force_rebuild`` triggers a full re-ingest equivalent to ``lilbee rebuild``. 

125 Use it to recover from an embedding-model switch (when the store refuses search 

126 or ingest because ``cfg.embedding_model`` no longer matches the persisted vectors). 

127 ``retry_skipped`` is the lighter recovery: it clears the markers for files that 

128 failed a previous sync (Tesseract timeout, decode failure, no usable text) so this 

129 sync attempts them again, without dropping the existing store. The default is an 

130 incremental sync. 

131 ``prune_ignored`` drops sources a ``.lilbeeignore`` now excludes. Off by default: 

132 the patterns govern what sync takes in, not what a past sync already indexed. 

133 """ 

134 

135 force_rebuild: bool = False 

136 retry_skipped: bool = False 

137 prune_ignored: bool = False 

138 

139 

140class AddRequest(_OcrRequest): 

141 """Request body for /api/add.""" 

142 

143 paths: list[str] 

144 force: bool = False 

145 

146 

147class SetModelRequest(BaseModel): 

148 """Request body for /api/models/chat.""" 

149 

150 model: str 

151 

152 

153class SourceContentResponse(BaseModel): 

154 """JSON body for ``GET /api/source`` (``raw=0``); empty ``markdown`` for binary types.""" 

155 

156 markdown: str 

157 content_type: str 

158 title: str | None = None 

159 

160 

161class ChatMessage(BaseModel): 

162 """A single message in a chat conversation.""" 

163 

164 role: Literal["user", "assistant"] 

165 content: str 

166 

167 

168class CleanedChunk(BaseModel): 

169 """A search result chunk with vector stripped and distance renamed.""" 

170 

171 source: str 

172 content_type: str 

173 chunk: str 

174 distance: float | None = None 

175 relevance_score: float | None = None 

176 rerank_score: float | None = None 

177 # Canonical [0, 1] relevance from retrieval fusion; the ranking signal 

178 # HTTP clients should sort and threshold on (relevance_score is legacy). 

179 score: float | None = None 

180 page_start: int = 0 

181 page_end: int = 0 

182 line_start: int = 0 

183 line_end: int = 0 

184 chunk_index: int = 0 

185 # Vault-relative path when ``cfg.vault_base`` is set and the source file 

186 # lives inside the vault. Absent when the server is running headless or 

187 # the source isn't resolvable as a vault file. Clients use this to open 

188 # the source in a native editor instead of fetching ``/api/source``. 

189 vault_path: str | None = None 

190 # Set only on recalled-memory sources (``source`` is ``memory:<id>``), so 

191 # clients can mark them as memory and link them to ``GET /api/memories``. 

192 memory_id: str | None = None 

193 # The plain one-line name of the source a client shows (``host · title`` for a 

194 # crawled page). Never escaped: a client shows it as text. 

195 label: str | None = None 

196 # The ``[n]`` the answer cites this source by. Absent on a dropped chunk whose 

197 # source no chunk in the answer's context holds. 

198 citation_number: int | None = None 

199 

200 

201class StatusSourceInfo(BaseModel): 

202 """A single indexed source in a status response.""" 

203 

204 filename: str 

205 file_hash: str 

206 chunk_count: int 

207 ingested_at: str 

208 

209 

210class StatusConfigInfo(BaseModel): 

211 """Configuration section of a status response. 

212 

213 Exposes all four role-bound model fields so plugins/TUI can show 

214 what's active per role without a second round trip. 

215 """ 

216 

217 documents_dir: str 

218 data_dir: str 

219 chat_model: str 

220 embedding_model: str 

221 vision_model: str = "" 

222 reranker_model: str = "" 

223 ocr: OcrMode = OcrMode.AUTO 

224 num_ctx: int | None = None 

225 num_ctx_max: int | None = None 

226 chat_n_ctx_target: int | None = None 

227 flash_attention: bool | None = None 

228 kv_cache_type: KvCacheType | None = None 

229 n_gpu_layers: int | None = None 

230 cpu_moe: bool | None = None 

231 n_cpu_moe: int | None = None 

232 gpu_devices: str | None = None 

233 

234 

235class StatusEntityInfo(BaseModel): 

236 """Entity-extraction section of a status response (present when enabled).""" 

237 

238 types: list[str] 

239 rows: int 

240 

241 

242class StatusIndexInfo(BaseModel): 

243 """The embedder that built the persisted index.""" 

244 

245 embedding_model: str 

246 embedding_dim: int 

247 

248 

249class StatusResponse(BaseModel): 

250 """Response for GET /api/status.""" 

251 

252 command: str = "status" 

253 config: StatusConfigInfo 

254 sources: list[StatusSourceInfo] 

255 """The newest sources, capped; ``document_count`` is the real count.""" 

256 source_kinds: dict[str, int] = {} 

257 """How many sources each kind holds, over the whole library.""" 

258 document_count: int 

259 total_chunks: int 

260 index: StatusIndexInfo | None = None 

261 """The embedder that built the index; absent before the first sync. Compare it 

262 with ``config.embedding_model`` to tell a stale index before a search refuses it.""" 

263 entities: StatusEntityInfo | None = None 

264 skipped: list[SkippedSource] = [] 

265 """Failed files held out of the index, capped; ``skipped_total`` is the real count.""" 

266 skipped_total: int = 0 

267 ocr_note: str = "" 

268 """What happens to scanned pages: skipped, or read by which engine.""" 

269 

270 

271class ShutdownResponse(BaseModel): 

272 """Response for /api/shutdown.""" 

273 

274 status: Literal["shutting_down"] 

275 

276 

277class HealthResponse(BaseModel): 

278 """Response for /api/health.""" 

279 

280 status: str 

281 version: str 

282 chat_ready: bool = False 

283 """True once the chat engine is loaded and ready to serve a first token. 

284 

285 A launcher polls this to wait out the cold model load before handing off to 

286 a client, so the client never lands on an apparently-dead stream. 

287 """ 

288 chat_status: Literal["ready", "loading", "not_started", "error"] = "not_started" 

289 """Finer-grained chat readiness than the ``chat_ready`` bool. 

290 

291 Lets a polling client tell a fleet that is still loading (wait) apart from one 

292 that never started warming (``not_started`` -- no chat model resolved / planned, 

293 so it will not come up on its own) or failed (``error``). Without this a bare 

294 ``chat_ready:false`` reads the same for "loading" and "hung", which looked like a 

295 silent hang on a fresh box with no chat model installed.""" 

296 chat_error: str | None = None 

297 """The reason the chat engine failed to come up when ``chat_status`` is 

298 ``error`` (e.g. a wedged GPU device probe), so a polling client can report 

299 the cause instead of retrying forever.""" 

300 chat_ctx: int | None = None 

301 """Per-slot context the chat engine serves, so a launcher can tell the client 

302 its window and the client trims history to fit. None until the engine is up.""" 

303 chat_slots: int | None = None 

304 """Batching slots the chat engine serves (its real request concurrency), so a 

305 script driving parallel agents can read the granted shape instead of assuming 

306 the configured one. None until the engine is up.""" 

307 chat_prefill_processed: int | None = None 

308 """Prompt tokens the chat engine has processed for a prefill in flight. A 

309 large model's first agent turn can spend minutes here with nothing streamed; 

310 polling this tells a working engine apart from a hung one. None when idle.""" 

311 chat_prefill_total: int | None = None 

312 """Prompt tokens the in-flight chat prefill will process in total. None when 

313 no prefill is running.""" 

314 embed_token_cap: int | None = None 

315 """Tokens the embedding engine truncates one input to. The chunker bounds its 

316 budget to this, so it is the largest chunk that reaches the index whole. None 

317 when no managed embedder is configured.""" 

318 warnings: list[HealthWarning] = [] 

319 """Degradations that answer correctly but worse, and a library upgrade that runs. 

320 

321 Retrieval falling back to vector-only, or an index whose documents predate 

322 the embedder's prefixes, both return results and look healthy.""" 

323 upgrade: LibraryUpgradeEvent | None = None 

324 """The one-time library upgrade while it runs: its phase, tables done of the 

325 total, the table in progress and the seconds since it began. None otherwise.""" 

326 

327 

328class CompactionInfo(BaseModel): 

329 """What one pre-turn compaction folded out of a conversation.""" 

330 

331 summary: str 

332 condensed: int 

333 """Turns folded into the notes.""" 

334 stranded: int 

335 """Turns dropped with no notes; a client must say so rather than hide it.""" 

336 

337 

338class AskResponse(BaseModel): 

339 """Response for /api/ask and /api/chat. 

340 

341 ``sources`` is the full retrieved set; ``cited_sources`` is the subset the answer 

342 actually cited, so a client can tell a grounded answer from an off-corpus one. 

343 """ 

344 

345 answer: str 

346 sources: list[CleanedChunk] 

347 cited_sources: list[CleanedChunk] = Field(default_factory=list) 

348 compaction: CompactionInfo | None = None 

349 """Set when a /api/chat turn compacted its history before answering.""" 

350 retrieval_query: str | None = None 

351 """The standalone rewrite retrieval ran on, when a follow-up was rewritten.""" 

352 dropped_sources: list[CleanedChunk] = Field(default_factory=list) 

353 """Chunks the budget fit shed, so a client can say what was trimmed.""" 

354 

355 

356class SetModelResponse(BaseModel): 

357 """Response for PUT /api/models/{chat|embedding|vision|reranker}. 

358 

359 ``reindex_required`` is ``True`` only when the new embedding model differs from 

360 the model that built the persisted vector store. The chat, vision, and reranker 

361 handlers always return ``False`` because their changes do not invalidate stored 

362 vectors. Mirrors the ``reindex_required`` flag on ``ConfigUpdateResponse``. 

363 ``warnings`` holds notices about the result, such as a vision model that 

364 ``ocr = off`` leaves unused. 

365 """ 

366 

367 model: str 

368 reindex_required: bool = False 

369 warnings: list[str] = [] 

370 

371 

372class ConfigResetRequest(BaseModel): 

373 """Request body for POST /api/config/reset.""" 

374 

375 keys: list[str] 

376 

377 

378class ConfigUpdateResponse(BaseModel): 

379 """Response for PATCH /api/config and POST /api/config/reset.""" 

380 

381 updated: list[str] 

382 reindex_required: bool 

383 warnings: list[str] = [] 

384 

385 @classmethod 

386 def from_result(cls, result: SettingsUpdateResult) -> ConfigUpdateResponse: 

387 """The canonical serialized settings update, shared by REST and the CLI.""" 

388 return cls( 

389 updated=result.updated, 

390 reindex_required=result.reindex_required, 

391 warnings=list(result.warnings), 

392 ) 

393 

394 

395class CrawlLoginBody(BaseModel): 

396 """The login a crawl sends to its seed host: an authorization header or a username and 

397 password, and a cookie header. An empty field is unset.""" 

398 

399 authorization: str = "" 

400 username: str = "" 

401 password: str = "" 

402 cookie: str = "" 

403 

404 

405class CrawlRequest(BaseModel): 

406 """Request body for /api/crawl. 

407 

408 depth: null / omitted = whole-site unbounded recursion. 0 = single URL 

409 only. Positive int = max depth. max_pages: null / omitted = the protective 

410 safety cap. 0 = explicitly unlimited (the CRAWL_PAGES_UNLIMITED sentinel the 

411 TUI and crawler honor). Positive int = explicit page cap. render_mode: null / 

412 omitted = configured default; "http" is browserless, "browser" runs Chromium 

413 with JavaScript. 

414 """ 

415 

416 url: str 

417 depth: int | None = Field(default=None, ge=0, le=MAX_CRAWL_DEPTH) 

418 max_pages: int | None = Field(default=None, ge=0, le=MAX_CRAWL_PAGES) 

419 render_mode: CrawlRenderMode | None = Field(default=None) 

420 include_subdomains: bool = Field(default=False) 

421 login: CrawlLoginBody | None = Field(default=None) 

422 

423 

424class DocumentInfo(BaseModel): 

425 """A single indexed document in a list response.""" 

426 

427 filename: str 

428 chunk_count: int = 0 

429 ingested_at: str = "" 

430 

431 

432class DocumentListResponse(BaseModel): 

433 """Response for GET /api/documents.""" 

434 

435 documents: list[DocumentInfo] 

436 total: int 

437 limit: int 

438 offset: int 

439 has_more: bool = False 

440 

441 

442class DocumentRemoveResponse(BaseModel): 

443 """Response for POST /api/documents/remove.""" 

444 

445 removed: list[str] = Field( 

446 description="Names removed: indexed sources, files an ingestion failure held out, " 

447 "and registered root labels." 

448 ) 

449 not_found: list[str] = Field(description="Names that matched nothing removable.") 

450 

451 

452class ConfigResponse(BaseModel): 

453 """Response for GET /api/config.""" 

454 

455 model_config = {"extra": "allow"} 

456 

457 

458class ConfigSourcesResponse(BaseModel): 

459 """Response for GET /api/config/sources: the layer that supplies each GET /api/config key.""" 

460 

461 sources: dict[str, SettingSource] 

462 

463 

464class ConfigFieldSchema(BaseModel): 

465 """Metadata for one configuration field, so a client can render its control. 

466 

467 Field names match the MCP ``settings_list`` wire shape, which carries the 

468 same metadata for agents. 

469 """ 

470 

471 key: str 

472 type: str 

473 nullable: bool 

474 writable: bool 

475 reindex_required: bool 

476 group: SettingGroup 

477 help: str 

478 choices: list[str] | None 

479 advanced: bool 

480 

481 

482class ConfigSchemaResponse(BaseModel): 

483 """Response for GET /api/config/schema.""" 

484 

485 fields: list[ConfigFieldSchema] 

486 

487 

488class SettingValueResponse(BaseModel): 

489 """One setting's current value and source, for the CLI's ``lilbee settings`` JSON output. 

490 

491 Field names match the MCP ``settings_list``/``settings_get`` wire shape. 

492 """ 

493 

494 key: str 

495 value: Any 

496 default: Any 

497 type: str 

498 nullable: bool 

499 group: SettingGroup 

500 help: str 

501 choices: list[str] | None 

502 reindex_required: bool 

503 source: SettingSource 

504 advanced: bool 

505 

506 @classmethod 

507 def from_info(cls, info: SettingInfo) -> SettingValueResponse: 

508 return cls( 

509 key=info.key, 

510 value=info.value, 

511 default=info.default, 

512 type=info.type, 

513 nullable=info.nullable, 

514 group=info.group, 

515 help=info.help_text, 

516 choices=list(info.choices) if info.choices else None, 

517 reindex_required=info.reindex_required, 

518 source=info.source, 

519 advanced=info.advanced, 

520 ) 

521 

522 

523class SettingsListResponse(BaseModel): 

524 """Response for the CLI's ``lilbee settings list --json``.""" 

525 

526 settings: list[SettingValueResponse] 

527 

528 @classmethod 

529 def from_infos(cls, infos: list[SettingInfo]) -> SettingsListResponse: 

530 return cls(settings=[SettingValueResponse.from_info(info) for info in infos]) 

531 

532 

533class ModelsShowResponse(BaseModel): 

534 """Response for POST /api/models/show.""" 

535 

536 model_config = {"extra": "allow"} 

537 

538 

539class CatalogEntryResponse(BaseModel): 

540 """A single model in the catalog browser. 

541 

542 ``fit`` and ``size_variants`` carry server-computed hardware-fit 

543 data so clients (TUI, plugin) can render fit chips and size strips 

544 without probing local memory themselves. ``fit`` is ``None`` when 

545 the row's footprint cannot be assessed against host memory (e.g. 

546 a future cloud-only entry whose weights live off-host). 

547 """ 

548 

549 hf_repo: str 

550 gguf_filename: str 

551 task: ModelTask 

552 display_name: str 

553 param_count: str 

554 size_gb: float 

555 min_ram_gb: float 

556 description: str 

557 quality_tier: str 

558 featured: bool 

559 downloads: int 

560 installed: bool 

561 source: ModelSource 

562 fit: FitLevel | None = None 

563 size_variants: list[SizeVariantInfo] = [] 

564 architecture: str = "" 

565 compat: ModelCompat = ModelCompat.UNKNOWN 

566 safety_stripped: bool = False 

567 provider: str = "" 

568 key_status: KeyStatus | None = None 

569 

570 

571class ModelsCatalogResponse(BaseModel): 

572 """Response for GET /api/models/catalog. 

573 

574 Filters apply before paging. ``next_offset`` names the offset to request 

575 next, None on the last page. ``truncated`` is True when the HuggingFace scan 

576 stopped at its bound with rows left unread, so the listing is cut short. 

577 """ 

578 

579 total: int | None 

580 limit: int 

581 offset: int 

582 models: list[CatalogEntryResponse] 

583 has_more: bool = False 

584 next_offset: int | None 

585 truncated: bool = False 

586 

587 

588class ModelsInstalledResponse(BaseModel): 

589 """Response for GET /api/models/installed.""" 

590 

591 models: list[ModelEntry] 

592 

593 

594class ModelsDeleteResponse(BaseModel): 

595 """Response for DELETE /api/models/{model}.""" 

596 

597 deleted: bool 

598 model: str 

599 freed_gb: float 

600 

601 

602class ExternalModelsResponse(BaseModel): 

603 """Response for GET /api/models/external.""" 

604 

605 models: list[str] 

606 error: str | None = None 

607 

608 

609class SyncSummary(BaseModel): 

610 """Embedded sync result within an add-files response.""" 

611 

612 added: list[str] = [] 

613 updated: list[str] = [] 

614 removed: list[str] = [] 

615 unchanged: int = 0 

616 relocated: list[str] = [] 

617 failed: list[str] = [] 

618 skipped: list[str] = [] 

619 held_out: list[SkippedSource] = [] 

620 truncated: int = 0 

621 unreadable_folders: list[str] = [] 

622 index_mismatch: IndexMismatch | None = None 

623 

624 

625class AddSummary(BaseModel): 

626 """Summary returned by the add-files handler.""" 

627 

628 copied: list[str] 

629 errors: list[str] 

630 name_taken: list[str] = [] 

631 """Labels held by a different source; nothing was registered and no sync ran.""" 

632 overlapping: list[str] = [] 

633 """Paths inside a registered source, or around the documents directory; none is registered.""" 

634 absorbed: list[str] = [] 

635 """Registered sources a path in ``copied`` took in; their documents moved below its name.""" 

636 tracked: list[str] = [] 

637 """Named sources the knowledge base already tracks, so nothing was registered. 

638 

639 These need no action from the caller: the sync in the same request covers them. 

640 """ 

641 sync: SyncSummary | None = None 

642 already_ingesting: list[str] = [] 

643 """Sources another ingest held a lock on, so this run never attempted them. 

644 

645 Distinct from ``skipped``, which means the file was examined and needed no 

646 work. These were not looked at and are worth retrying. Carried on the 

647 terminal event so a client that missed the earlier ``already_ingesting`` 

648 frames can still tell the batch was partial. 

649 """ 

650 

651 

652class WikiCitationRecord(BaseModel): 

653 """A citation record from the store, used in reverse lookup responses.""" 

654 

655 wiki_source: str = "" 

656 wiki_chunk_index: int = 0 

657 citation_key: str = "" 

658 claim_type: str = "fact" 

659 source_filename: str = "" 

660 source_hash: str = "" 

661 page_start: int = 0 

662 page_end: int = 0 

663 line_start: int = 0 

664 line_end: int = 0 

665 excerpt: str = "" 

666 created_at: str = "" 

667 

668 

669class WikiEntityCandidateResponse(BaseModel): 

670 """One NER entity candidate, with the evidence a page would be built from.""" 

671 

672 slug: str 

673 label: str = "" 

674 kind: EntityKind = EntityKind.ENTITY 

675 type_hint: str = "" 

676 mentions: int = 0 

677 sources: list[str] = [] 

678 

679 

680class WikiBuildDryRunResult(BaseModel): 

681 """Entity candidates a build would cover, with no LLM call made.""" 

682 

683 dry_run: bool = True 

684 entities: list[WikiEntityCandidateResponse] = [] 

685 count: int = 0 

686 note: str = "" 

687 

688 

689class WikiPageDetail(BaseModel): 

690 """Full content of a single wiki page, with its parsed frontmatter.""" 

691 

692 slug: str 

693 title: str = "" 

694 content: str = "" 

695 frontmatter: dict[str, Any] = {} 

696 

697 

698class WikiCitationsResult(BaseModel): 

699 """Citations attached to a single wiki page.""" 

700 

701 slug: str 

702 citations: list[WikiCitationRecord] = [] 

703 

704 

705class WikiLintIssueItem(BaseModel): 

706 """A single lint finding on a wiki page.""" 

707 

708 wiki_source: str = "" 

709 issue_type: str = "" 

710 severity: str = "" 

711 message: str = "" 

712 

713 

714class WikiLintResult(BaseModel): 

715 """Result of a wiki lint run, whole-wiki or single-page.""" 

716 

717 issues: list[WikiLintIssueItem] = [] 

718 total: int = 0 

719 errors: int = 0 

720 warnings: int = 0 

721 

722 

723class WikiPruneRecordResponse(BaseModel): 

724 """A single pruning action.""" 

725 

726 wiki_source: str 

727 action: str 

728 reason: str 

729 

730 

731class WikiPruneResult(BaseModel): 

732 """Result of wiki pruning.""" 

733 

734 records: list[WikiPruneRecordResponse] = [] 

735 archived: int = 0 

736 flagged: int = 0 

737 reconciled: int = 0 

738 

739 

740class WikiIndexResult(BaseModel): 

741 """Result of rebuilding the browse index. Costs no LLM call.""" 

742 

743 entries: int = 0 

744 

745 

746class WikiGenerateResult(BaseModel): 

747 """Result of generating one indexed page.""" 

748 

749 slug: str 

750 path: str 

751 

752 

753class WikiWipeResult(BaseModel): 

754 """Result of wiping the wiki. 

755 

756 ``rows_deleted`` is false when the pages went but the store delete failed, 

757 so a client is never told the wiki is gone while its rows still answer. 

758 """ 

759 

760 pages_removed: int = 0 

761 sources_cleared: int = 0 

762 rows_deleted: bool = True 

763 

764 

765class WikiStatusResult(BaseModel): 

766 """Wiki layer status counters.""" 

767 

768 wiki_enabled: bool 

769 summaries: int = 0 

770 drafts: int = 0 

771 pages: int = 0 

772 lint_errors: int = 0 

773 lint_warnings: int = 0 

774 

775 

776class DraftInfoResponse(BaseModel): 

777 """Metadata about a single wiki draft, mirroring ``DraftInfo.to_dict()``. 

778 

779 ``pending_kind`` distinguishes drift drafts (``None``) from 

780 batched-generation markers (``"parse"``, ``"collision"``). 

781 """ 

782 

783 slug: str 

784 path: str 

785 drift_ratio: float | None = None 

786 faithfulness_score: float | None = None 

787 bad_title: bool = False 

788 published_path: str | None = None 

789 published_exists: bool = False 

790 mtime: float = 0.0 

791 pending_kind: str | None = None 

792 

793 

794class WikiDraftDiffResponse(BaseModel): 

795 """Unified diff of a draft against its published counterpart.""" 

796 

797 slug: str 

798 diff: str 

799 

800 

801class WikiDraftAcceptResponse(BaseModel): 

802 """Outcome of accepting a draft: where it landed and how many chunks reindexed. 

803 

804 ``slug`` is the slug where the content was published. 

805 ``requested_slug`` is the slug the client asked to accept. The two 

806 differ for PENDING-COLLISION drafts, where the request slug carries 

807 a ``-collision-<hash>`` suffix that is stripped on publish. 

808 """ 

809 

810 slug: str 

811 requested_slug: str 

812 moved_to: str 

813 reindexed_chunks: int 

814 

815 

816class WikiDraftRejectResponse(BaseModel): 

817 """Outcome of rejecting a draft.""" 

818 

819 slug: str 

820 

821 

822class RememberRequest(BaseModel): 

823 """Request body for ``POST /api/memories``.""" 

824 

825 text: str 

826 kind: MemoryKind = MemoryKind.FACT 

827 shared: bool = False 

828 

829 

830class RememberResponse(BaseModel): 

831 """Outcome of storing a memory.""" 

832 

833 id: str 

834 kind: MemoryKind 

835 

836 

837class MemoryItem(BaseModel): 

838 """A single stored memory in a list response.""" 

839 

840 id: str 

841 kind: MemoryKind 

842 shared: bool 

843 text: str 

844 

845 

846class MemoryListResponse(BaseModel): 

847 """Body for ``GET /api/memories``.""" 

848 

849 memories: list[MemoryItem] 

850 

851 

852class MemorySharedRequest(BaseModel): 

853 """Request body for ``PATCH /api/memories/{memory_id}``.""" 

854 

855 shared: bool 

856 

857 

858class MemoryFlagsResponse(BaseModel): 

859 """Outcome of a flag update; ``updated`` is False when the id was unknown.""" 

860 

861 id: str 

862 updated: bool 

863 

864 

865class MemoryRemoveResponse(BaseModel): 

866 """Outcome of deleting a memory; ``deleted`` is False when the id was unknown.""" 

867 

868 id: str 

869 deleted: bool 

870 

871 

872class MemoryExtractedItem(BaseModel): 

873 """A single memory created by auto-extraction during a chat turn.""" 

874 

875 id: str 

876 kind: MemoryKind 

877 text: str 

878 

879 

880class MemoryExtractedEvent(BaseModel): 

881 """``memory_extracted`` SSE payload: how many memories a turn auto-saved. 

882 

883 Emitted on the chat stream after ``done`` when auto-extraction is on and the 

884 turn produced at least one memory, so a REST client (the Obsidian plugin) can 

885 toast the count and refresh its memories view without a separate fetch. 

886 """ 

887 

888 count: int 

889 items: list[MemoryExtractedItem] 

890 

891 

892class GpuInfoResponse(BaseModel): 

893 """One GPU as returned by GET /api/gpus and embedded in PlacementResponse.""" 

894 

895 index: int 

896 backend: str 

897 label: str 

898 name: str 

899 total_bytes: int 

900 free_bytes: int 

901 

902 

903class GpusResponse(BaseModel): 

904 """GET /api/gpus envelope: detected GPUs plus the host-level util notice.""" 

905 

906 gpus: list[GpuInfoResponse] 

907 notice: str | None = None 

908 

909 

910class RolePlacementResponse(BaseModel): 

911 """Where one role's model is placed in the resolved plan.""" 

912 

913 role: WorkerRole 

914 model: str 

915 devices: list[int] 

916 tensor_split: list[int] | None 

917 replicas: int 

918 

919 

920class SkippedRoleResponse(BaseModel): 

921 """A configured role left unplaced because its model isn't downloaded.""" 

922 

923 role: WorkerRole 

924 model: str 

925 

926 

927class PlacementResponse(BaseModel): 

928 """Response for placement read, preview, set, and clear routes.""" 

929 

930 gpus: list[GpuInfoResponse] 

931 roles: list[RolePlacementResponse] 

932 unplaceable: list[str] 

933 manual: bool 

934 spec_json: str | None 

935 skipped_not_installed: list[SkippedRoleResponse] = [] 

936 co_tenants: list[str] = [] 

937 notice: str | None = None 

938 rejected_spec_json: str | None = None 

939 # The backend the engine selected, reported rather than inferred. ``gpus`` 

940 # being empty does not mean ``cpu``: a host whose device probe never answered 

941 # reports ``unknown``, so a client never mislabels a GPU box as a CPU one. 

942 engine_backend: EngineBackend = EngineBackend.UNKNOWN 

943 

944 @classmethod 

945 def from_view(cls, view: PlacementView) -> PlacementResponse: 

946 """The canonical serialized placement view, shared by the HTTP, MCP, and CLI surfaces.""" 

947 return cls( 

948 gpus=[GpuInfoResponse(**vars(g)) for g in view.gpus], 

949 roles=[ 

950 RolePlacementResponse( 

951 role=r.role, 

952 model=r.model, 

953 devices=list(r.devices), 

954 tensor_split=list(r.tensor_split) if r.tensor_split else None, 

955 replicas=r.replicas, 

956 ) 

957 for r in view.roles 

958 ], 

959 unplaceable=[r.value for r in view.unplaceable], 

960 manual=view.manual, 

961 spec_json=view.spec_json, 

962 skipped_not_installed=[ 

963 SkippedRoleResponse(role=s.role, model=s.model) for s in view.skipped_not_installed 

964 ], 

965 co_tenants=[r.value for r in view.co_tenants], 

966 rejected_spec_json=view.rejected_spec_json, 

967 engine_backend=view.engine_backend, 

968 ) 

969 

970 

971class PlacementSpecBody(BaseModel): 

972 """Request body for placement routes that accept a manual spec.""" 

973 

974 spec: dict[str, dict[str, object]] | None = None 

975 

976 

977class SessionMetaItem(BaseModel): 

978 """A session's metadata in a list or detail response.""" 

979 

980 id: str 

981 title: str 

982 created_at: str 

983 updated_at: str 

984 model_ref: str 

985 scope: str 

986 message_count: int 

987 origin: str = "tui" 

988 """Owning surface. tui/http/cli are one domain and append freely to each 

989 other's sessions; appends across the human/agent (mcp) boundary are 409.""" 

990 forked_from: str = "" 

991 """Id of the session this one was forked from; empty when it is not a fork.""" 

992 

993 

994class SessionListResponse(BaseModel): 

995 """Body for ``GET /api/sessions``.""" 

996 

997 sessions: list[SessionMetaItem] 

998 

999 

1000class SessionSourceRefInput(BaseModel): 

1001 """One entry of a turn's Sources list as a client saves it.""" 

1002 

1003 source: str 

1004 # The ``[n]`` the answer cites the source by; null when unknown. 

1005 citation_number: int | None = None 

1006 # The page or line span the answer read, such as "page 3"; "" when none. 

1007 locator: str = "" 

1008 

1009 

1010class SessionSourceRef(SessionSourceRefInput): 

1011 """One entry of a turn's Sources list, with the label to show computed at read time.""" 

1012 

1013 # The plain one-line name of the source. Never escaped: a client shows it as text. 

1014 label: str 

1015 

1016 

1017class SessionMessageItem(BaseModel): 

1018 """One message in a session transcript. 

1019 

1020 ``content`` never holds lilbee's Sources list: ``source_refs`` carries it. 

1021 """ 

1022 

1023 role: MessageRole 

1024 content: str 

1025 sources: list[str] 

1026 ts: str 

1027 source_refs: list[SessionSourceRef] = [] 

1028 

1029 

1030class SessionDetailResponse(BaseModel): 

1031 """Body for ``GET /api/sessions/{session_id}``: metadata plus transcript. 

1032 

1033 ``summary`` carries what compaction folded the oldest turns into (empty when 

1034 a conversation has not been compacted). A client that resumes and continues 

1035 the conversation needs it: without it, it rebuilds history from the raw 

1036 transcript, re-sending turns the summary had already condensed and risking 

1037 the context overflow compaction exists to prevent. 

1038 """ 

1039 

1040 meta: SessionMetaItem 

1041 messages: list[SessionMessageItem] 

1042 summary: str = "" 

1043 

1044 

1045class SessionCreateRequest(BaseModel): 

1046 """Request body for ``POST /api/sessions``.""" 

1047 

1048 model_ref: str 

1049 scope: str 

1050 

1051 

1052class SessionMessageCreateRequest(BaseModel): 

1053 """Request body for ``POST /api/sessions/{session_id}/messages``.""" 

1054 

1055 role: MessageRole 

1056 content: str 

1057 sources: list[str] = [] 

1058 # The answer's Sources list. When given, ``sources`` is derived from it. 

1059 source_refs: list[SessionSourceRefInput] | None = None 

1060 

1061 

1062class SessionForkRequest(BaseModel): 

1063 """Request body for ``POST /api/sessions/{session_id}/fork``. 

1064 

1065 ``message_count`` is the number of leading messages to copy; null copies all. 

1066 """ 

1067 

1068 message_count: int | None = None 

1069 

1070 @field_validator("message_count", mode="before") 

1071 @classmethod 

1072 def _check_message_count(cls, value: object) -> object: 

1073 """Refuse ``true``, ``"2"`` and ``1.0`` rather than coerce them into a count.""" 

1074 # isinstance: the raw JSON value, before pydantic's lax coercion runs. 

1075 if value is None or (isinstance(value, int) and not isinstance(value, bool)): 

1076 return value 

1077 raise ValueError("message_count must be a whole number or null") 

1078 

1079 

1080class SessionSummaryRequest(BaseModel): 

1081 """Request body for ``PUT /api/sessions/{session_id}/summary``.""" 

1082 

1083 summary: str 

1084 

1085 

1086class SessionRenameRequest(BaseModel): 

1087 """Request body for ``PATCH /api/sessions/{session_id}``.""" 

1088 

1089 title: str 

1090 

1091 

1092class SessionRenameResponse(BaseModel): 

1093 """Outcome of a rename.""" 

1094 

1095 id: str 

1096 title: str 

1097 

1098 

1099class SessionDeleteResponse(BaseModel): 

1100 """Outcome of a delete.""" 

1101 

1102 id: str 

1103 deleted: bool 

1104 

1105 

1106class AgentClientDetection(BaseModel): 

1107 """Whether one agent client's CLI is installed on the machine lilbee runs on.""" 

1108 

1109 client: AgentClient 

1110 cli_detected: bool 

1111 cli_path: str | None 

1112 

1113 

1114class AgentConfigIndexResponse(BaseModel): 

1115 """Response for ``GET /api/agent-config``: every client lilbee can configure.""" 

1116 

1117 clients: list[AgentClientDetection] 

1118 

1119 @classmethod 

1120 def from_detections(cls, detections: list[ClientDetection]) -> AgentConfigIndexResponse: 

1121 """Serialize the probe results one entry per supported client.""" 

1122 return cls( 

1123 clients=[ 

1124 AgentClientDetection( 

1125 client=found.client, 

1126 cli_detected=found.cli_detected, 

1127 cli_path=found.cli_path, 

1128 ) 

1129 for found in detections 

1130 ] 

1131 ) 

1132 

1133 

1134class AgentConfigResponse(BaseModel): 

1135 """Response for ``GET /api/agent-config/{client}``: one client's live config. 

1136 

1137 ``config`` carries the block for a JSON client, ``content`` the rendered text 

1138 for a YAML one. ``stdio_config`` is the alternative block for a client that 

1139 can also run lilbee as a subprocess instead of calling this server. 

1140 """ 

1141 

1142 client: AgentClient 

1143 format: ConfigFormat 

1144 surfaces: list[AgentSurface] 

1145 config: dict[str, Any] | None = None 

1146 content: str | None = None 

1147 stdio_config: dict[str, Any] | None = None 

1148 

1149 @classmethod 

1150 def from_document(cls, document: AgentConfigDocument) -> AgentConfigResponse: 

1151 """The canonical serialized config document, shared by the HTTP and CLI surfaces.""" 

1152 return cls( 

1153 client=document.client, 

1154 format=document.format, 

1155 surfaces=list(document.surfaces), 

1156 config=document.config, 

1157 content=document.content, 

1158 stdio_config=document.stdio_config, 

1159 ) 

1160 

1161 

1162class ProfileAuthorResponse(BaseModel): 

1163 """One credited author of a profile.""" 

1164 

1165 name: str 

1166 github: str | None 

1167 

1168 

1169class ProfileEntryResponse(BaseModel): 

1170 """One profile file; ``error`` names why a broken file cannot be used.""" 

1171 

1172 name: str 

1173 folder: ProfileFolder 

1174 path: str 

1175 valid: bool 

1176 error: str | None 

1177 shadowed_by: ProfileFolder | None 

1178 description: str | None 

1179 authors: list[ProfileAuthorResponse] 

1180 credit: str | None 

1181 tested_on: str | None 

1182 evidence: str | None 

1183 min_lilbee: str | None 

1184 values: dict[str, Any] 

1185 

1186 @classmethod 

1187 def from_entry(cls, entry: ProfileEntry) -> ProfileEntryResponse: 

1188 """The canonical serialized profile, shared by the HTTP, MCP, and CLI surfaces.""" 

1189 profile = entry.file 

1190 authors = profile.authors if profile is not None else () 

1191 return cls( 

1192 name=entry.name, 

1193 folder=entry.folder, 

1194 path=entry.path.as_posix(), 

1195 valid=profile is not None, 

1196 error=entry.error, 

1197 shadowed_by=entry.shadowed_by, 

1198 description=profile.description if profile is not None else None, 

1199 authors=[ProfileAuthorResponse(name=a.name, github=a.github) for a in authors], 

1200 credit=credit_line(authors), 

1201 tested_on=profile.tested_on if profile is not None else None, 

1202 evidence=profile.evidence if profile is not None else None, 

1203 min_lilbee=profile.min_lilbee if profile is not None else None, 

1204 values=dict(profile.values) if profile is not None else {}, 

1205 ) 

1206 

1207 

1208class ProfileListResponse(BaseModel): 

1209 """Response for GET /api/profiles: every profile file, Default first, built-ins next.""" 

1210 

1211 profiles: list[ProfileEntryResponse] 

1212 

1213 

1214class ProfileChangeRowResponse(BaseModel): 

1215 """A profile setting you set, and the value and source it falls back to without you.""" 

1216 

1217 key: str 

1218 yours: Any 

1219 profile_value: Any 

1220 profile_source: SettingSource 

1221 effect: ProfileEffect 

1222 

1223 @classmethod 

1224 def from_row(cls, row: ChangeRow) -> ProfileChangeRowResponse: 

1225 """One serialized change row.""" 

1226 return cls( 

1227 key=row.key, 

1228 yours=row.yours, 

1229 profile_value=row.profile_value, 

1230 profile_source=row.profile_source, 

1231 effect=row.effect, 

1232 ) 

1233 

1234 

1235class ActiveProfileResponse(BaseModel): 

1236 """The project's applied profile, its recorded values, your changes, and its file's state.""" 

1237 

1238 name: str 

1239 status: ProfileStatus 

1240 error: str | None 

1241 values: dict[str, Any] 

1242 changes: list[ProfileChangeRowResponse] 

1243 profile: ProfileEntryResponse | None 

1244 

1245 @classmethod 

1246 def from_active(cls, current: ActiveProfile) -> ActiveProfileResponse: 

1247 """The canonical serialized active profile, shared by every surface.""" 

1248 entry = current.entry 

1249 return cls( 

1250 name=current.name, 

1251 status=current.status, 

1252 error=current.error, 

1253 values=dict(current.values), 

1254 changes=[ProfileChangeRowResponse.from_row(row) for row in current.changes], 

1255 profile=ProfileEntryResponse.from_entry(entry) if entry is not None else None, 

1256 ) 

1257 

1258 

1259class ProfileDiffRowResponse(BaseModel): 

1260 """One setting a profile apply changes, and when the change takes effect.""" 

1261 

1262 key: str 

1263 current: Any 

1264 current_source: SettingSource 

1265 new: Any 

1266 effect: ProfileEffect 

1267 

1268 @classmethod 

1269 def from_row(cls, row: DiffRow) -> ProfileDiffRowResponse: 

1270 """One serialized diff row.""" 

1271 return cls( 

1272 key=row.key, 

1273 current=row.current, 

1274 current_source=row.current_source, 

1275 new=row.new, 

1276 effect=row.effect, 

1277 ) 

1278 

1279 

1280class ProfileDiffResponse(BaseModel): 

1281 """Response for GET /api/profiles/{name}/diff.""" 

1282 

1283 name: str 

1284 changes: list[ProfileDiffRowResponse] 

1285 kept: list[str] 

1286 untouched_count: int 

1287 

1288 @classmethod 

1289 def from_diff(cls, diff: ProfileDiff) -> ProfileDiffResponse: 

1290 """The canonical serialized diff, shared by every surface.""" 

1291 return cls( 

1292 name=diff.name, 

1293 changes=[ProfileDiffRowResponse.from_row(row) for row in diff.changes], 

1294 kept=list(diff.kept), 

1295 untouched_count=diff.untouched_count, 

1296 ) 

1297 

1298 

1299class ProfileApplyResponse(BaseModel): 

1300 """Response for POST /api/profiles/{name}/apply.""" 

1301 

1302 name: str 

1303 changes: list[ProfileDiffRowResponse] 

1304 reindex_required: bool 

1305 new_files_only: list[str] 

1306 kept: list[str] 

1307 warnings: list[str] = [] 

1308 

1309 @classmethod 

1310 def from_result(cls, result: ApplyResult) -> ProfileApplyResponse: 

1311 """The canonical serialized apply result, shared by every surface.""" 

1312 return cls( 

1313 name=result.name, 

1314 changes=[ProfileDiffRowResponse.from_row(row) for row in result.changes], 

1315 reindex_required=result.reindex_required, 

1316 new_files_only=list(result.new_files_only), 

1317 kept=list(result.kept), 

1318 warnings=list(result.warnings), 

1319 ) 

1320 

1321 

1322class ProfileLocationResponse(BaseModel): 

1323 """A profile file an operation wrote or removed.""" 

1324 

1325 name: str 

1326 folder: ProfileFolder 

1327 path: str 

1328 

1329 @classmethod 

1330 def from_location(cls, location: ProfileLocation) -> ProfileLocationResponse: 

1331 """One serialized profile location.""" 

1332 return cls(name=location.name, folder=location.folder, path=location.path.as_posix()) 

1333 

1334 

1335class ProfileSaveResponse(ProfileLocationResponse): 

1336 """A saved profile file, the settings of yours it took over, and any notice the write 

1337 raises.""" 

1338 

1339 absorbed: list[str] 

1340 warnings: list[str] = [] 

1341 

1342 @classmethod 

1343 def from_save(cls, result: SaveResult) -> ProfileSaveResponse: 

1344 """The canonical serialized save or update result, shared by every surface.""" 

1345 location = result.location 

1346 return cls( 

1347 name=location.name, 

1348 folder=location.folder, 

1349 path=location.path.as_posix(), 

1350 absorbed=list(result.absorbed), 

1351 warnings=list(result.warnings), 

1352 ) 

1353 

1354 

1355class ProfileDiscardResponse(BaseModel): 

1356 """Response for POST /api/profiles/discard: your settings removed, whether to rebuild, 

1357 and any notice the discard raises.""" 

1358 

1359 dropped: list[str] 

1360 reindex_required: bool 

1361 warnings: list[str] = [] 

1362 

1363 @classmethod 

1364 def from_result(cls, result: DiscardResult) -> ProfileDiscardResponse: 

1365 """The canonical serialized discard result, shared by every surface.""" 

1366 return cls( 

1367 dropped=list(result.dropped), 

1368 reindex_required=result.reindex_required, 

1369 warnings=list(result.warnings), 

1370 ) 

1371 

1372 

1373class ProfileValidationResponse(BaseModel): 

1374 """Every problem found in one profile file; ``valid`` when there is none.""" 

1375 

1376 name: str 

1377 valid: bool 

1378 problems: list[str] 

1379 

1380 @classmethod 

1381 def from_validation(cls, result: ProfileValidation) -> ProfileValidationResponse: 

1382 """The canonical serialized validation, shared by every surface.""" 

1383 return cls(name=result.name, valid=result.valid, problems=list(result.problems)) 

1384 

1385 

1386class ProfileSaveRequest(BaseModel): 

1387 """Request body for POST /api/profiles: save the project's settings as a new profile.""" 

1388 

1389 name: str 

1390 target: ProfileFolder = ProfileFolder.GLOBAL 

1391 

1392 

1393class ProfileNewRequest(BaseModel): 

1394 """Request body for POST /api/profiles/new: a template, optionally from a profile.""" 

1395 

1396 name: str 

1397 from_profile: str | None = None 

1398 target: ProfileFolder = ProfileFolder.GLOBAL 

1399 

1400 

1401class ProfileDuplicateRequest(BaseModel): 

1402 """Request body for POST /api/profiles/{name}/duplicate.""" 

1403 

1404 new_name: str 

1405 target: ProfileFolder = ProfileFolder.GLOBAL 

1406 

1407 

1408class ProfileRenameRequest(BaseModel): 

1409 """Request body for PATCH /api/profiles/{name}.""" 

1410 

1411 new_name: str 

1412 

1413 

1414class ProfileImportRequest(BaseModel): 

1415 """Request body for POST /api/profiles/import: a profile file's text and its file name.""" 

1416 

1417 content: str 

1418 filename: str 

1419 target: ProfileFolder = ProfileFolder.GLOBAL 

1420 overwrite: bool = False 

1421 

1422 

1423class ProfileValidateRequest(BaseModel): 

1424 """Request body for POST /api/profiles/validate: a profile file's text and its file name.""" 

1425 

1426 content: str 

1427 filename: str 

1428 folder: ProfileFolder = ProfileFolder.GLOBAL 

1429 

1430 

1431class AnalyzeFailureResponse(BaseModel): 

1432 """A sampled file analyze could not read.""" 

1433 

1434 file: str 

1435 error: str 

1436 

1437 

1438class AnalyzePdfResponse(BaseModel): 

1439 """PDF pages, scans and tables; ``scanned_share`` also counts image files at the sample rate.""" 

1440 

1441 files: int 

1442 pages: int 

1443 scanned_pages: int 

1444 scanned_share: float 

1445 files_with_tables: int 

1446 tables: int 

1447 median_pages: float | None 

1448 

1449 @classmethod 

1450 def from_signals(cls, pdf: PdfSignals) -> AnalyzePdfResponse: 

1451 """The serialized PDF signals.""" 

1452 return cls( 

1453 files=pdf.files, 

1454 pages=pdf.pages, 

1455 scanned_pages=pdf.scanned_pages, 

1456 scanned_share=pdf.scanned_share, 

1457 files_with_tables=pdf.files_with_tables, 

1458 tables=pdf.tables, 

1459 median_pages=pdf.median_pages, 

1460 ) 

1461 

1462 

1463class AnalyzeLanguageResponse(BaseModel): 

1464 """A detected language (ISO 639-3), its share of text files, its stemmer, and OCR support.""" 

1465 

1466 code: str 

1467 share: float 

1468 fts_language: FtsLanguage | None 

1469 ocr_supported: bool 

1470 

1471 @classmethod 

1472 def from_row(cls, row: LanguageRow) -> AnalyzeLanguageResponse: 

1473 """One serialized language row.""" 

1474 return cls( 

1475 code=row.code, 

1476 share=row.share, 

1477 fts_language=row.fts_language, 

1478 ocr_supported=row.ocr_supported, 

1479 ) 

1480 

1481 

1482class AnalyzeReasonResponse(BaseModel): 

1483 """Why the recommendation sets a setting; the key ``profile`` explains the built-in pick.""" 

1484 

1485 key: str 

1486 text: str 

1487 

1488 

1489class AnalyzeRecommendationResponse(BaseModel): 

1490 """The picked built-in, the derived profile, and what applying it changes.""" 

1491 

1492 builtin: str 

1493 name: str | None 

1494 values: dict[str, Any] 

1495 changes: list[ProfileDiffRowResponse] 

1496 kept: list[str] 

1497 reasons: list[AnalyzeReasonResponse] 

1498 notes: list[str] 

1499 

1500 @classmethod 

1501 def from_recommendation(cls, rec: Recommendation) -> AnalyzeRecommendationResponse: 

1502 """The serialized recommendation.""" 

1503 return cls( 

1504 builtin=rec.builtin, 

1505 name=rec.name, 

1506 values=dict(rec.values), 

1507 changes=[ProfileDiffRowResponse.from_row(row) for row in rec.changes], 

1508 kept=list(rec.kept), 

1509 reasons=[AnalyzeReasonResponse(key=r.key, text=r.text) for r in rec.reasons], 

1510 notes=list(rec.notes), 

1511 ) 

1512 

1513 

1514class AnalyzeSavedResponse(ProfileLocationResponse): 

1515 """The profile analyze saved or switched to, its file, whether the project now uses it, 

1516 and any notice it raises.""" 

1517 

1518 applied: bool 

1519 warnings: list[str] = [] 

1520 

1521 @classmethod 

1522 def from_saved(cls, saved: SavedProfile) -> AnalyzeSavedResponse: 

1523 """The serialized saved profile.""" 

1524 return cls( 

1525 name=saved.name, 

1526 folder=saved.folder, 

1527 path=saved.path.as_posix(), 

1528 applied=saved.applied, 

1529 warnings=list(saved.warnings), 

1530 ) 

1531 

1532 

1533class AnalyzeResponse(BaseModel): 

1534 """One analyze run: what it read and what it recommends; the shape every surface returns. 

1535 

1536 ``files_read`` counts the documents extracted without error, out of ``documents_total``; 

1537 ``files_counted`` code, image and archive files are counted and never read. 

1538 """ 

1539 

1540 files_total: int 

1541 documents_total: int 

1542 files_read: int 

1543 files_counted: int 

1544 cap: int 

1545 failed: list[AnalyzeFailureResponse] 

1546 unreadable_folders: list[str] 

1547 file_types: dict[str, int] 

1548 code_share: float 

1549 pdf: AnalyzePdfResponse 

1550 median_chars: float | None 

1551 languages: list[AnalyzeLanguageResponse] 

1552 recommendation: AnalyzeRecommendationResponse 

1553 saved: AnalyzeSavedResponse | None 

1554 

1555 @classmethod 

1556 def from_report(cls, report: AnalyzeReport) -> AnalyzeResponse: 

1557 """The canonical serialized analyze report, shared by every surface.""" 

1558 signals = report.signals 

1559 return cls( 

1560 files_total=signals.files_total, 

1561 documents_total=signals.documents_total, 

1562 files_read=signals.files_read, 

1563 files_counted=signals.files_counted, 

1564 cap=signals.cap, 

1565 failed=[AnalyzeFailureResponse(file=f.file, error=f.error) for f in signals.failed], 

1566 unreadable_folders=list(signals.unreadable_folders), 

1567 file_types=dict(signals.file_types), 

1568 code_share=signals.code_share, 

1569 pdf=AnalyzePdfResponse.from_signals(signals.pdf), 

1570 median_chars=signals.median_chars, 

1571 languages=[AnalyzeLanguageResponse.from_row(row) for row in report.languages], 

1572 recommendation=AnalyzeRecommendationResponse.from_recommendation(report.recommendation), 

1573 saved=None if report.saved is None else AnalyzeSavedResponse.from_saved(report.saved), 

1574 ) 

1575 

1576 

1577class AnalyzeRequestBody(BaseModel): 

1578 """Request body for POST /api/analyze. 

1579 

1580 No ``directory`` reads the corpus; otherwise an absolute folder on the server. 

1581 ``save`` names the saved profile, ``apply`` also switches to it, and ``target`` 

1582 (project or global) needs one of them. 

1583 """ 

1584 

1585 directory: str | None = None 

1586 apply: bool = False 

1587 save: str | None = None 

1588 target: ProfileFolder | None = None 

1589 

1590 

1591class AnalyzeStateResponse(BaseModel): 

1592 """Whether the project was analyzed, hid the analyze tip, and would see the tip now.""" 

1593 

1594 analyzed: bool 

1595 tip_dismissed: bool 

1596 tip_shows: bool 

1597 

1598 @classmethod 

1599 def from_state(cls, state: TipState) -> AnalyzeStateResponse: 

1600 """The tip state as sent over HTTP and MCP.""" 

1601 return cls( 

1602 analyzed=state.analyzed, tip_dismissed=state.tip_dismissed, tip_shows=state.tip_shows 

1603 )