diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..dfe0770424b2a19faf507a501ebfc23be8f54e7b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3bb4e9b2616221a67bd1228b811263bb92e4fe22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ + +# Node +node_modules/ +*.log + +# Editors +.idea/ +.vscode/ + +# Build outputs +dist/ +build/ + +# OS +Thumbs.db +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1340df18e3c1ca8d7fa1a264bbe4b1b4330238b2 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# RightCodes Architecture Scaffold + +This repository is the starting scaffold for the code and standards migration assistant. The layout mirrors the planned AI-driven workflow (ingestion -> extraction -> mapping -> rewrite -> validation -> export) and keeps agent orchestration, backend services, and UI concerns separated. + +## Top-Level Directories + +- `frontend/` - React application for uploads, diff review, validation checklists, and export actions. +- `server/` - FastAPI backend that manages sessions, orchestrates agents, and exposes REST/WebSocket APIs. +- `agents/` - OpenAI agent wrappers plus prompt assets for each processing stage. +- `workers/` - File ingestion, document parsing, and pipeline jobs executed off the main API thread. +- `storage/` - Versioned document blobs, manifests, caches, and export artefacts. +- `docs/` - Architecture notes, pipeline diagrams, agent specs, and UI flows. +- `scripts/` - Developer utilities, operational scripts, and local tooling hooks. +- `data/` - Sample inputs, canonical standards metadata, and fixture mappings. +- `infra/` - DevOps assets (containers, CI pipelines, observability). +- `common/` - Shared domain models, schemas, and event definitions. + +## Getting Started + +### Prerequisites + +- Python 3.8+ (3.11+ recommended) +- Node.js 18+ and npm (ships with Node.js) +- Internet connectivity on the first launch so pip/npm can download dependencies + +### Launcher Quick Start + +The repository ships with `start-rightcodes.ps1` (PowerShell) and `start-rightcodes.bat` (Command Prompt) which check for Python and Node.js, create the virtual environment, install dependencies, and open the launcher UI. + +```powershell +.\start-rightcodes.ps1 +``` + +```bat +start-rightcodes.bat +``` + +The scripts will guide you to the official Python and Node.js installers if either prerequisite is missing. After a successful first run, the cached `.venv/` and `frontend/node_modules/` folders allow the launcher to work offline. + +### Backend (FastAPI) + +```bash +cd server +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +# copy the sample env and add your OpenAI key +copy .env.example .env # or use New-Item -Path .env -ItemType File +``` + +Edit `.env` and set `RIGHTCODES_OPENAI_API_KEY=sk-your-key`. + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +The API is available at `http://localhost:8000/api` and Swagger UI at `http://localhost:8000/api/docs`. + +### Frontend (Vite + React) + +```bash +cd frontend +npm install +npm run dev +``` + +Navigate to `http://localhost:5173` to access the UI. Configure a custom API base URL by setting `VITE_API_BASE_URL` in `frontend/.env`. + +## Usage + +- Each conversion session requires the original report (`.docx`) plus one or more destination standards packs (`.pdf`). Upload all relevant standards PDFs so the AI pipeline can align existing references with the new context. +- The web UI now shows live progress bars and an activity log so you can monitor each stage of the pipeline while it runs. +- Backend agent calls expect an OpenAI API key in `RIGHTCODES_OPENAI_API_KEY`. On Windows PowerShell you can set it temporarily with `setx RIGHTCODES_OPENAI_API_KEY "sk-..."` (reopen your terminal afterward). +- Optional: override the default OpenAI models by setting `RIGHTCODES_OPENAI_MODEL_EXTRACT`, `RIGHTCODES_OPENAI_MODEL_MAPPING`, `RIGHTCODES_OPENAI_MODEL_REWRITE`, `RIGHTCODES_OPENAI_MODEL_VALIDATE`, and `RIGHTCODES_OPENAI_MODEL_EMBED`. The pipeline stores standards embeddings under `storage/embeddings/`, enabling retrieval-augmented mapping, and the export stage now writes a converted DOCX to `storage/exports/` with a download button once the pipeline completes. + +## Offline Distribution Options + +- **Zip bundle:** Run `python tools/build_offline_package.py` after a successful online launch. The script creates `dist/rightcodes-offline.zip` containing the repository, the prepared `.venv/`, and `frontend/node_modules/`. Pass `--python-runtime` and `--node-runtime` to embed portable runtimes if you have them (for example, a locally extracted Python embeddable zip and Node.js binary folder). Extract the archive on another machine (same OS/architecture) and use the launcher scripts without needing internet access. +- **Docker image:** Build a pre-baked container with `docker build -t rightcodes-launcher -f docker/Dockerfile .`. Supply `RIGHTCODES_OPENAI_API_KEY` (and optionally `RIGHTCODES_OPENAI_API_KEY_SOURCE`) at runtime: `docker run --rm -p 8765:8765 -p 8000:8000 -p 5173:5173 -e RIGHTCODES_OPENAI_API_KEY=sk-xxx rightcodes-launcher`. +- **First-run reminder:** Even with these assets, the *initial* bundle build still requires internet so dependencies can be fetched once before packaging. + +## Next Steps + +1. Flesh out agent logic in `agents/` and integrate with orchestration hooks. +2. Replace the in-memory session store with a durable persistence layer. +3. Wire the worker queue (`workers/queue/`) to execute long-running stages asynchronously. diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2cf821d2df2ea0b0a3ba2f240369024aaf711c58 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,13 @@ +# Agent Bundles + +Encapsulates prompts, tool definitions, and orchestration glue for each OpenAI Agent engaged in the pipeline. + +## Structure + +- `orchestrator/` - Coordinator agent spec, high-level playbooks, and session controller logic. +- `extraction/` - Document clause extraction prompts, tool configs for parsers, and output schemas. +- `standards_mapping/` - Normalization/mapping prompts, ontology helpers, and reference datasets. +- `rewrite/` - Minimal-change rewrite prompts, safety rules, and diff formatting helpers. +- `validation/` - Post-rewrite review prompts, calculation sanity checks, and reporting templates. +- `export/` - Finalization prompts, merge instructions, and docx regeneration aids. +- `shared/` - Common prompt fragments, JSON schema definitions, and evaluation heuristics. diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..694dc3993b8a2dac9f085478bf7c64517dadc4ba --- /dev/null +++ b/agents/__init__.py @@ -0,0 +1,15 @@ +from .orchestrator.agent import OrchestratorAgent +from .extraction.agent import ExtractionAgent +from .standards_mapping.agent import StandardsMappingAgent +from .rewrite.agent import RewriteAgent +from .validation.agent import ValidationAgent +from .export.agent import ExportAgent + +__all__ = [ + "OrchestratorAgent", + "ExtractionAgent", + "StandardsMappingAgent", + "RewriteAgent", + "ValidationAgent", + "ExportAgent", +] diff --git a/agents/export/__init__.py b/agents/export/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9f97147915126e71b4f5c060df5400a9f7995542 --- /dev/null +++ b/agents/export/__init__.py @@ -0,0 +1,3 @@ +from .agent import ExportAgent + +__all__ = ["ExportAgent"] diff --git a/agents/export/agent.py b/agents/export/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..06ca7a8a26db8b5595c6976b8d52f2c1ab8c7ed0 --- /dev/null +++ b/agents/export/agent.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from docx import Document + +from server.app.services.diagnostics_service import get_diagnostics_service + +from ..shared.base import AgentContext, BaseAgent + + +def _apply_table_replacements(document: Document, replacements: List[Dict[str, Any]]) -> int: + tables = document.tables + applied = 0 + for item in replacements: + index = item.get("table_index") + updated_rows = item.get("updated_rows") + if not isinstance(index, int) or index < 0 or index >= len(tables): + continue + if not isinstance(updated_rows, list): + continue + table = tables[index] + for row_idx, row_values in enumerate(updated_rows): + if row_idx < len(table.rows): + row = table.rows[row_idx] + else: + row = table.add_row() + for col_idx, value in enumerate(row_values): + if col_idx < len(row.cells): + row.cells[col_idx].text = str(value) if value is not None else "" + else: + break + applied += 1 + return applied + + +def _apply_replacements(document: Document, replacements: List[Dict[str, Any]]) -> int: + applied = 0 + paragraphs = document.paragraphs + for item in replacements: + try: + index = int(item.get("paragraph_index")) + except (TypeError, ValueError): + continue + if index < 0 or index >= len(paragraphs): + continue + updated_text = item.get("updated_text") + if not isinstance(updated_text, str): + continue + paragraphs[index].text = updated_text + applied += 1 + return applied + + +class ExportAgent(BaseAgent): + name = "export-agent" + + async def run(self, context: AgentContext) -> Dict[str, Any]: + await self.emit_debug("Exporting updated document to DOCX.") + + rewrite_plan = context.payload.get("rewrite_plan") or {} + replacements = rewrite_plan.get("replacements") or [] + table_replacements = rewrite_plan.get("table_replacements") or [] + source_path = context.payload.get("original_path") + if not source_path or not Path(source_path).exists(): + raise RuntimeError("Original document path not supplied to export agent.") + + document = Document(source_path) + applied_paragraphs = _apply_replacements(document, replacements) + applied_tables = _apply_table_replacements(document, table_replacements) + + storage_root = _resolve_storage_root() + export_dir = Path(storage_root) / "exports" + export_dir.mkdir(parents=True, exist_ok=True) + export_path = export_dir / f"{context.session_id}-converted.docx" + document.save(export_path) + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="exports", + event_type="export.generated", + message=f"Generated export for session `{context.session_id}`", + metadata={ + "session_id": context.session_id, + "path": str(export_path), + "paragraph_replacements": applied_paragraphs, + "table_updates": applied_tables, + }, + ) + + if applied_paragraphs or applied_tables: + note = "Converted document generated using rewrite plan." + else: + note = "Export completed, but no replacements were applied." + + return { + "export_path": str(export_path), + "notes": note, + "replacement_count": applied_paragraphs, + "table_replacement_count": applied_tables, + "generated_at": datetime.utcnow().isoformat(), + } + + +def _resolve_storage_root() -> Path: + try: + from server.app.core.config import get_settings # local import avoids circular dependency + + return get_settings().storage_dir + except Exception: # noqa: BLE001 + return (Path(__file__).resolve().parents[2] / "storage").resolve() diff --git a/agents/extraction/__init__.py b/agents/extraction/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab6f9a789cc83fb5bcbf57d38eb9c94ae9bf8af9 --- /dev/null +++ b/agents/extraction/__init__.py @@ -0,0 +1,3 @@ +from .agent import ExtractionAgent + +__all__ = ["ExtractionAgent"] diff --git a/agents/extraction/agent.py b/agents/extraction/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..d2394bdecfadb4cd4ea4d97f8f59aac9b2494438 --- /dev/null +++ b/agents/extraction/agent.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +import json +from typing import Any, Dict, Iterable, List + +from ..shared.base import AgentContext, BaseAgent + + +class ExtractionAgent(BaseAgent): + name = "extraction-agent" + paragraph_chunk_size = 40 + table_chunk_size = 15 + max_text_chars = 1500 + + async def run(self, context: AgentContext) -> Dict[str, Any]: + raw_paragraphs = context.payload.get("paragraphs", []) + raw_tables = context.payload.get("tables", []) + paragraphs = [_prepare_paragraph(item, self.max_text_chars) for item in _normalise_items(raw_paragraphs)] + tables = [_prepare_table(item, self.max_text_chars) for item in _normalise_items(raw_tables)] + metadata = context.payload.get("metadata", {}) + + if not paragraphs and not tables: + await self.emit_debug("No document content supplied to extraction agent.") + return { + "document_summary": "", + "sections": [], + "tables": [], + "references": [], + "notes": "Skipped: no document content provided.", + } + + schema = { + "name": "ExtractionResult", + "schema": { + "type": "object", + "properties": { + "document_summary": {"type": "string"}, + "sections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "paragraph_index": {"type": "integer"}, + "text": {"type": "string"}, + "references": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + }, + "required": ["paragraph_index", "text"], + "additionalProperties": False, + }, + "default": [], + }, + "tables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "table_index": {"type": "integer"}, + "summary": {"type": "string"}, + "references": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + }, + "required": ["table_index", "summary"], + "additionalProperties": False, + }, + "default": [], + }, + "references": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + "notes": {"type": "string"}, + }, + "required": ["document_summary", "sections", "tables", "references"], + "additionalProperties": False, + }, + } + + model = self.settings.openai_model_extract + aggregated_sections: List[Dict[str, Any]] = [] + aggregated_tables: List[Dict[str, Any]] = [] + aggregated_references: set[str] = set() + summaries: List[str] = [] + notes: List[str] = [] + + for chunk in _chunk_list(paragraphs, self.paragraph_chunk_size): + batch = await self._process_batch( + context=context, + model=model, + schema=schema, + paragraphs=chunk, + tables=[], + metadata=metadata, + ) + if batch: + summaries.append(batch.get("document_summary", "")) + aggregated_sections.extend(batch.get("sections", [])) + aggregated_tables.extend(batch.get("tables", [])) + aggregated_references.update(batch.get("references", [])) + if batch.get("notes"): + notes.append(batch["notes"]) + + for chunk in _chunk_list(tables, self.table_chunk_size): + batch = await self._process_batch( + context=context, + model=model, + schema=schema, + paragraphs=[], + tables=chunk, + metadata=metadata, + ) + if batch: + aggregated_tables.extend(batch.get("tables", [])) + aggregated_references.update(batch.get("references", [])) + if batch.get("notes"): + notes.append(batch["notes"]) + + summary = " ".join(filter(None, summaries)).strip() + + for item in paragraphs: + aggregated_references.update(item.get("references", [])) + for item in tables: + aggregated_references.update(item.get("references", [])) + + return { + "document_summary": summary, + "sections": aggregated_sections, + "tables": aggregated_tables, + "references": sorted(aggregated_references), + "notes": " ".join(notes).strip(), + } + + async def _process_batch( + self, + *, + context: AgentContext, + model: str, + schema: Dict[str, Any], + paragraphs: List[Dict[str, Any]], + tables: List[Dict[str, Any]], + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + if not paragraphs and not tables: + return {} + + payload = { + "paragraphs": paragraphs, + "tables": tables, + "metadata": metadata, + } + + messages = [ + { + "role": "system", + "content": ( + "You are an engineering standards analyst. " + "Analyse the supplied report content, identify normative references, " + "and return structured data following the JSON schema." + ), + }, + { + "role": "user", + "content": ( + f"Session ID: {context.session_id}\n" + f"Payload: {json.dumps(payload, ensure_ascii=False)}" + ), + }, + ] + + try: + result = await self.call_openai_json(model=model, messages=messages, schema=schema) + return result + except Exception as exc: # noqa: BLE001 + await self.emit_debug(f"Extraction chunk failed: {exc}") + return { + "document_summary": "", + "sections": [], + "tables": [], + "references": [], + "notes": f"Chunk failed: {exc}", + } + + +def _normalise_items(items: List[Any]) -> List[Dict[str, Any]]: + normalised: List[Dict[str, Any]] = [] + for item in items: + if is_dataclass(item): + normalised.append(asdict(item)) + elif isinstance(item, dict): + normalised.append(item) + else: + normalised.append({"value": str(item)}) + return normalised + + +def _prepare_paragraph(item: Dict[str, Any], max_chars: int) -> Dict[str, Any]: + text = item.get("text", "") + if len(text) > max_chars: + text = text[:max_chars] + "...(trimmed)" + return { + "index": item.get("index"), + "text": text, + "style": item.get("style"), + "heading_level": item.get("heading_level"), + "references": item.get("references", []), + } + + +def _prepare_table(item: Dict[str, Any], max_chars: int) -> Dict[str, Any]: + rows = item.get("rows", []) + preview_rows = [] + for row in rows: + preview_row = [] + for cell in row: + cell_text = str(cell) + if len(cell_text) > max_chars: + cell_text = cell_text[:max_chars] + "...(trimmed)" + preview_row.append(cell_text) + preview_rows.append(preview_row) + return { + "index": item.get("index"), + "rows": preview_rows, + "references": item.get("references", []), + } + + +def _chunk_list(items: List[Dict[str, Any]], size: int) -> Iterable[List[Dict[str, Any]]]: + if size <= 0: + size = len(items) or 1 + for idx in range(0, len(items), size): + yield items[idx : idx + size] diff --git a/agents/orchestrator/__init__.py b/agents/orchestrator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1e4be55f7405d1b789f8d3acc0c5d9c1a0ca16e1 --- /dev/null +++ b/agents/orchestrator/__init__.py @@ -0,0 +1,3 @@ +from .agent import OrchestratorAgent + +__all__ = ["OrchestratorAgent"] diff --git a/agents/orchestrator/agent.py b/agents/orchestrator/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..dc3fc263f1c05244beee600126b4d2393f38c0e6 --- /dev/null +++ b/agents/orchestrator/agent.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any, Dict + +from ..shared.base import AgentContext, BaseAgent + + +class OrchestratorAgent(BaseAgent): + name = "orchestrator-agent" + + async def run(self, context: AgentContext) -> Dict[str, Any]: + await self.emit_debug(f"Received session {context.session_id}") + # In a future iteration this agent will orchestrate sub-agent calls. + return { + "next_stage": "ingest", + "notes": "Placeholder orchestrator response.", + "input_payload": context.payload, + } diff --git a/agents/rewrite/__init__.py b/agents/rewrite/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..971a30bc1c9db81943c8ba5d16c5d319c3738b9c --- /dev/null +++ b/agents/rewrite/__init__.py @@ -0,0 +1,3 @@ +from .agent import RewriteAgent + +__all__ = ["RewriteAgent"] diff --git a/agents/rewrite/agent.py b/agents/rewrite/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..e6c4aa75edc799d2acb30263c529355cae8c0787 --- /dev/null +++ b/agents/rewrite/agent.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Iterable, List, Sequence + +from ..shared.base import AgentContext, BaseAgent + + +class RewriteAgent(BaseAgent): + name = "rewrite-agent" + paragraph_chunk_size = 20 + max_paragraph_chars = 1600 + max_table_chars = 1200 + + async def run(self, context: AgentContext) -> Dict[str, Any]: + mapping_result = context.payload.get("mapping_result") or {} + mappings: List[Dict[str, Any]] = mapping_result.get("mappings", []) + if not mappings: + await self.emit_debug("Rewrite skipped: no mappings provided.") + return { + "replacements": [], + "table_replacements": [], + "change_log": [], + "notes": "Rewrite skipped: no mappings provided.", + } + + mapping_by_reference = _index_mappings(mappings) + doc_paragraphs = _normalise_paragraphs( + context.payload.get("document_paragraphs", []), self.max_paragraph_chars + ) + doc_tables = _normalise_tables( + context.payload.get("document_tables", []), self.max_table_chars + ) + + paragraphs_to_rewrite = [ + paragraph + for paragraph in doc_paragraphs + if any(ref in mapping_by_reference for ref in paragraph["references"]) + ] + tables_to_rewrite = [ + table + for table in doc_tables + if any(ref in mapping_by_reference for ref in table["references"]) + ] + + if not paragraphs_to_rewrite and not tables_to_rewrite: + await self.emit_debug("Rewrite skipped: no paragraphs or tables matched mapped references.") + return { + "replacements": [], + "table_replacements": [], + "change_log": [], + "notes": "Rewrite skipped: no references found in document.", + } + + aggregated_replacements: List[Dict[str, Any]] = [] + aggregated_table_replacements: List[Dict[str, Any]] = [] + change_log_entries: List[Dict[str, Any]] = [] + change_log_seen: set[tuple] = set() + notes: List[str] = [] + target_voice = context.payload.get("target_voice", "Professional engineering tone") + constraints = context.payload.get("constraints", []) + + pending_tables = {table["index"]: table for table in tables_to_rewrite} + + for chunk in _chunk_list(paragraphs_to_rewrite, self.paragraph_chunk_size): + relevant_refs = sorted( + { + reference + for paragraph in chunk + for reference in paragraph["references"] + if reference in mapping_by_reference + } + ) + if not relevant_refs: + continue + + mapping_subset = _collect_mapping_subset(mapping_by_reference, relevant_refs) + associated_tables = _collect_tables_for_refs(pending_tables, relevant_refs) + + payload = { + "instructions": { + "target_voice": target_voice, + "constraints": constraints, + "guidance": [ + "Preserve numbering, bullet markers, and formatting cues.", + "Do not alter calculations, quantities, or engineering values.", + "Only update normative references and surrounding wording necessary for clarity.", + "Maintain section titles and headings.", + ], + }, + "paragraphs": chunk, + "tables": associated_tables, + "mappings": mapping_subset, + } + + schema = _rewrite_schema() + messages = [ + { + "role": "system", + "content": ( + "You are an engineering editor updating a report so its references align with the target standards. " + "Return JSON matching the schema. Maintain original structure and numbering while replacing each reference with the mapped target references." + ), + }, + { + "role": "user", + "content": ( + f"Session: {context.session_id}\n" + f"Payload: {json.dumps(payload, ensure_ascii=False)}" + ), + }, + ] + + try: + result = await self.call_openai_json( + model=self.settings.openai_model_rewrite, + messages=messages, + schema=schema, + ) + aggregated_replacements.extend(result.get("replacements", [])) + aggregated_table_replacements.extend(result.get("table_replacements", [])) + for entry in result.get("change_log", []): + key = ( + entry.get("reference"), + entry.get("target_reference"), + tuple(entry.get("affected_paragraphs", [])), + ) + if key not in change_log_seen: + change_log_entries.append(entry) + change_log_seen.add(key) + if result.get("notes"): + notes.append(result["notes"]) + except Exception as exc: # noqa: BLE001 + await self.emit_debug(f"Rewrite chunk failed: {exc}") + notes.append(f"Rewrite chunk failed: {exc}") + + if not aggregated_replacements and not aggregated_table_replacements: + return { + "replacements": [], + "table_replacements": [], + "change_log": change_log_entries, + "notes": "Rewrite completed but no updates were suggested.", + } + + return { + "replacements": aggregated_replacements, + "table_replacements": aggregated_table_replacements, + "change_log": change_log_entries, + "notes": " ".join(notes).strip(), + } + + +def _index_mappings(mappings: Sequence[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: + index: Dict[str, List[Dict[str, Any]]] = {} + for mapping in mappings: + ref = mapping.get("source_reference") + if not isinstance(ref, str): + continue + index.setdefault(ref, []).append(mapping) + return index + + +def _collect_mapping_subset( + mapping_by_reference: Dict[str, List[Dict[str, Any]]], + references: Sequence[str], +) -> List[Dict[str, Any]]: + subset: List[Dict[str, Any]] = [] + for reference in references: + subset.extend(mapping_by_reference.get(reference, [])) + return subset + + +def _collect_tables_for_refs( + pending_tables: Dict[int, Dict[str, Any]], + references: Sequence[str], +) -> List[Dict[str, Any]]: + matched: List[Dict[str, Any]] = [] + for index in list(pending_tables.keys()): + table = pending_tables[index] + if any(ref in references for ref in table["references"]): + matched.append(table) + pending_tables.pop(index, None) + return matched + + +def _normalise_paragraphs(items: Sequence[Dict[str, Any]], max_chars: int) -> List[Dict[str, Any]]: + paragraphs: List[Dict[str, Any]] = [] + for item in items: + index = item.get("index") + if index is None: + continue + text = str(item.get("text", "")) + if len(text) > max_chars: + text = text[:max_chars] + "...(trimmed)" + paragraphs.append( + { + "index": index, + "text": text, + "style": item.get("style"), + "heading_level": item.get("heading_level"), + "references": item.get("references", []), + } + ) + return paragraphs + + +def _normalise_tables(items: Sequence[Dict[str, Any]], max_chars: int) -> List[Dict[str, Any]]: + tables: List[Dict[str, Any]] = [] + for item in items: + index = item.get("index") + if index is None: + continue + rows = [] + for row in item.get("rows", []): + preview_row = [] + for cell in row: + cell_text = str(cell) + if len(cell_text) > max_chars: + cell_text = cell_text[:max_chars] + "...(trimmed)" + preview_row.append(cell_text) + rows.append(preview_row) + tables.append( + { + "index": index, + "rows": rows, + "references": item.get("references", []), + } + ) + return tables + + +def _chunk_list(items: Sequence[Dict[str, Any]], size: int) -> Iterable[List[Dict[str, Any]]]: + if size <= 0: + size = len(items) or 1 + for idx in range(0, len(items), size): + yield list(items[idx : idx + size]) + + +def _rewrite_schema() -> Dict[str, Any]: + return { + "name": "RewritePlanChunk", + "schema": { + "type": "object", + "properties": { + "replacements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "paragraph_index": {"type": "integer"}, + "original_text": {"type": "string"}, + "updated_text": {"type": "string"}, + "applied_mappings": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + "change_reason": {"type": "string"}, + }, + "required": ["paragraph_index", "updated_text"], + "additionalProperties": False, + }, + "default": [], + }, + "table_replacements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "table_index": {"type": "integer"}, + "updated_rows": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + }, + "default": [], + }, + "applied_mappings": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + "change_reason": {"type": "string"}, + }, + "required": ["table_index"], + "additionalProperties": False, + }, + "default": [], + }, + "change_log": { + "type": "array", + "items": { + "type": "object", + "properties": { + "reference": {"type": "string"}, + "target_reference": {"type": "string"}, + "affected_paragraphs": { + "type": "array", + "items": {"type": "integer"}, + "default": [], + }, + "note": {"type": "string"}, + }, + "required": ["reference", "target_reference"], + "additionalProperties": False, + }, + "default": [], + }, + "notes": {"type": "string"}, + }, + "required": ["replacements", "table_replacements", "change_log"], + "additionalProperties": False, + }, + } diff --git a/agents/shared/base.py b/agents/shared/base.py new file mode 100644 index 0000000000000000000000000000000000000000..18599b1deb29438a0a226bba2ad4d3195b7bd7bc --- /dev/null +++ b/agents/shared/base.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict + +# We import config lazily to avoid circular imports during module initialisation. +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from server.app.core.config import Settings # pragma: no cover + + +@dataclass +class AgentContext: + session_id: str + payload: Dict[str, Any] = field(default_factory=dict) + + +class BaseAgent(ABC): + name: str + + @abstractmethod + async def run(self, context: AgentContext) -> Dict[str, Any]: + """Execute agent logic and return structured output.""" + + async def emit_debug(self, message: str) -> None: + # Placeholder until logging/event bus is wired in. + print(f"[{self.name}] {message}") + + @property + def settings(self): + from server.app.core.config import get_settings # import here to avoid circular dependency + + return get_settings() + + async def call_openai_json( + self, + *, + model: str, + messages: list[Dict[str, Any]], + schema: Dict[str, Any], + ) -> Dict[str, Any]: + from .client import create_json_response # import here to avoid circular dependency + + if not self.settings.openai_api_key: + await self.emit_debug("OpenAI API key missing; returning empty response.") + raise RuntimeError("OpenAI API key missing") + return await create_json_response(model=model, messages=messages, schema=schema) diff --git a/agents/shared/client.py b/agents/shared/client.py new file mode 100644 index 0000000000000000000000000000000000000000..844e37d9c8b90bd0d887d9756f0bcb32d2247f21 --- /dev/null +++ b/agents/shared/client.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from typing import Any, Iterable + +from openai import AsyncOpenAI, OpenAIError + +try: + from server.app.core.config import get_settings + from server.app.services.diagnostics_service import get_diagnostics_service +except ModuleNotFoundError as exc: # pragma: no cover + raise RuntimeError( + "Failed to import server configuration. Ensure the project root is on PYTHONPATH." + ) from exc + +logger = logging.getLogger(__name__) + + +@lru_cache +def get_openai_client() -> AsyncOpenAI: + settings = get_settings() + if not settings.openai_api_key: + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="openai", + event_type="openai.missing_key", + message="OpenAI API key missing; requests will fail.", + metadata={}, + ) + raise RuntimeError( + "OpenAI API key is not configured. Set RIGHTCODES_OPENAI_API_KEY before invoking agents." + ) + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="openai", + event_type="openai.client_ready", + message="OpenAI client initialised.", + metadata={}, + ) + return AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_api_base) + + +async def create_json_response( + *, + model: str, + messages: Iterable[dict[str, Any]], + schema: dict[str, Any], +) -> dict[str, Any]: + """Invoke OpenAI with a JSON schema response format.""" + client = get_openai_client() + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="openai", + event_type="openai.request", + message=f"Requesting model `{model}`", + metadata={"model": model}, + ) + try: + response = await client.chat.completions.create( + model=model, + messages=list(messages), + response_format={"type": "json_schema", "json_schema": schema}, + ) + except OpenAIError as exc: + logger.exception("OpenAI call failed: %s", exc) + diagnostics.record_event( + node_id="openai", + event_type="openai.error", + message="OpenAI request failed.", + metadata={"model": model, "error": str(exc)}, + ) + raise + + try: + choice = response.choices[0] + content = choice.message.content if choice and choice.message else None + if not content: + raise RuntimeError("OpenAI response did not include message content.") + payload = json.loads(content) + diagnostics.record_event( + node_id="openai", + event_type="openai.response", + message="Received OpenAI response.", + metadata={"model": model}, + ) + return payload + except (AttributeError, json.JSONDecodeError) as exc: + logger.exception("Failed to decode OpenAI response: %s", exc) + diagnostics.record_event( + node_id="openai", + event_type="openai.error", + message="Failed to decode OpenAI response.", + metadata={"model": model, "error": str(exc)}, + ) + raise RuntimeError("OpenAI response was not valid JSON.") from exc diff --git a/agents/shared/embeddings.py b/agents/shared/embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..477896aaf1cc4a4c1282854d900c1f66d7adf894 --- /dev/null +++ b/agents/shared/embeddings.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Iterable, List, Sequence + + + +async def embed_texts(texts: Iterable[str]) -> List[List[float]]: + texts = [text if text else "" for text in texts] + if not texts: + return [] + from ..shared.client import get_openai_client + client = get_openai_client() + from server.app.core.config import get_settings + settings = get_settings() + response = await client.embeddings.create( + model=settings.openai_model_embed, + input=list(texts), + ) + return [item.embedding for item in response.data] diff --git a/agents/standards_mapping/__init__.py b/agents/standards_mapping/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dd6d92a3e4ec2ba4a30f39df0e58e9885e6ee216 --- /dev/null +++ b/agents/standards_mapping/__init__.py @@ -0,0 +1,3 @@ +from .agent import StandardsMappingAgent + +__all__ = ["StandardsMappingAgent"] diff --git a/agents/standards_mapping/agent.py b/agents/standards_mapping/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..fd9085f5b6eb8a8673d1674e40d6f20a0a5fbd77 --- /dev/null +++ b/agents/standards_mapping/agent.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List + +from ..shared.base import AgentContext, BaseAgent +from ..shared.embeddings import embed_texts +from common.embedding_store import EmbeddingStore, get_session_embedding_path + + +class StandardsMappingAgent(BaseAgent): + name = "standards-mapping-agent" + reference_chunk_size = 20 + max_excerpt_chars = 800 + + async def run(self, context: AgentContext) -> Dict[str, Any]: + extraction_result = context.payload.get("extraction_result") or {} + references: List[str] = extraction_result.get("references") or [] + sections = extraction_result.get("sections") or [] + tables = extraction_result.get("tables") or [] + standards_chunks = _normalise_items(context.payload.get("standards_chunks", [])) + target_metadata = context.payload.get("target_metadata", {}) + store = EmbeddingStore(get_session_embedding_path(context.session_id)) + + if not references or not standards_chunks: + await self.emit_debug("Insufficient data for standards mapping.") + return { + "mappings": [], + "unmapped_references": references, + "notes": "Mapping skipped due to missing references or standards content.", + } + + schema = { + "name": "StandardsMapping", + "schema": { + "type": "object", + "properties": { + "mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_reference": {"type": "string"}, + "source_context": {"type": "string"}, + "target_reference": {"type": "string"}, + "target_clause": {"type": "string"}, + "target_summary": {"type": "string"}, + "confidence": {"type": "number"}, + "rationale": {"type": "string"}, + }, + "required": [ + "source_reference", + "target_reference", + "confidence", + ], + "additionalProperties": False, + }, + "default": [], + }, + "unmapped_references": { + "type": "array", + "items": {"type": "string"}, + "default": [], + }, + "notes": {"type": "string"}, + }, + "required": ["mappings", "unmapped_references"], + "additionalProperties": False, + }, + } + + standards_overview = _build_standards_overview(standards_chunks, self.max_excerpt_chars) + + model = self.settings.openai_model_mapping + aggregated_mappings: List[Dict[str, Any]] = [] + aggregated_unmapped: set[str] = set() + notes: List[str] = [] + + for chunk in _chunk_list(references, self.reference_chunk_size): + reference_context = _build_reference_context( + chunk, sections, tables, self.max_excerpt_chars + ) + retrieved_candidates = await _retrieve_candidates( + chunk, reference_context, store, target_metadata, self.max_excerpt_chars + ) + payload = { + "references": chunk, + "reference_context": reference_context, + "retrieved_candidates": [ + {"reference": ref, "candidates": retrieved_candidates.get(ref, [])} + for ref in chunk + ], + "standards_overview": standards_overview, + "target_metadata": target_metadata, + } + + messages = [ + { + "role": "system", + "content": ( + "You are an engineering standards migration specialist. " + "Map each legacy reference to the best matching clause in the target standards. " + "Use the provided context and standards overview to justify your mapping. " + "Return JSON that conforms to the supplied schema." + ), + }, + { + "role": "user", + "content": ( + f"Session: {context.session_id}\n" + f"Payload: {json.dumps(payload, ensure_ascii=False)}" + ), + }, + ] + + try: + result = await self.call_openai_json(model=model, messages=messages, schema=schema) + aggregated_mappings.extend(result.get("mappings", [])) + aggregated_unmapped.update(result.get("unmapped_references", [])) + if result.get("notes"): + notes.append(result["notes"]) + except Exception as exc: # noqa: BLE001 + await self.emit_debug(f"Standards mapping chunk failed: {exc}") + aggregated_unmapped.update(chunk) + notes.append(f"Chunk failed: {exc}") + + await self.emit_debug("Standards mapping completed via OpenAI.") + return { + "mappings": aggregated_mappings, + "unmapped_references": sorted(aggregated_unmapped), + "notes": " ".join(notes).strip(), + } + + +def _normalise_items(items: List[Any]) -> List[Dict[str, Any]]: + normalised: List[Dict[str, Any]] = [] + for item in items: + if is_dataclass(item): + normalised.append(asdict(item)) + elif isinstance(item, dict): + normalised.append(item) + else: + normalised.append({"text": str(item)}) + return normalised + + +def _chunk_list(items: List[str], size: int) -> Iterable[List[str]]: + if size <= 0: + size = len(items) or 1 + for idx in range(0, len(items), size): + yield items[idx : idx + size] + + +def _build_reference_context( + references: List[str], + sections: List[Dict[str, Any]], + tables: List[Dict[str, Any]], + max_chars: int, +) -> List[Dict[str, Any]]: + section_map: Dict[str, List[Dict[str, Any]]] = {} + for section in sections: + refs = section.get("references") or [] + for ref in refs: + section_map.setdefault(ref, []) + if len(section_map[ref]) < 3: + text = section.get("text", "") + if len(text) > max_chars: + text = text[:max_chars] + "...(trimmed)" + section_map[ref].append( + { + "paragraph_index": section.get("paragraph_index"), + "text": text, + } + ) + table_map: Dict[str, List[Dict[str, Any]]] = {} + for table in tables: + refs = table.get("references") or [] + for ref in refs: + table_map.setdefault(ref, []) + if len(table_map[ref]) < 2: + table_map[ref].append({"table_index": table.get("table_index"), "references": refs}) + + context = [] + for ref in references: + context.append( + { + "reference": ref, + "paragraphs": section_map.get(ref, []), + "tables": table_map.get(ref, []), + } + ) + return context + + +def _build_standards_overview( + standards_chunks: List[Dict[str, Any]], + max_chars: int, +) -> List[Dict[str, Any]]: + grouped: Dict[str, Dict[str, Any]] = {} + for chunk in standards_chunks: + path = chunk.get("path", "unknown") + heading = chunk.get("heading") + clauses = chunk.get("clause_numbers") or [] + text = chunk.get("text", "") + if len(text) > max_chars: + text = text[:max_chars] + "...(trimmed)" + + group = grouped.setdefault( + path, + { + "document": Path(path).name, + "headings": [], + "clauses": [], + "snippets": [], + }, + ) + if heading and heading not in group["headings"] and len(group["headings"]) < 120: + group["headings"].append(heading) + for clause in clauses: + if clause not in group["clauses"] and len(group["clauses"]) < 120: + group["clauses"].append(clause) + if text and len(group["snippets"]) < 30: + group["snippets"].append(text) + + overview: List[Dict[str, Any]] = [] + for data in grouped.values(): + overview.append( + { + "document": data["document"], + "headings": data["headings"][:50], + "clauses": data["clauses"][:50], + "snippets": data["snippets"], + } + ) + return overview[:30] + + +async def _retrieve_candidates( + references: List[str], + reference_context: List[Dict[str, Any]], + store: EmbeddingStore, + target_metadata: Dict[str, Any], + max_chars: int, +) -> Dict[str, List[Dict[str, Any]]]: + if not references: + return {} + if store.is_empty: + return {ref: [] for ref in references} + + context_lookup = {entry["reference"]: entry for entry in reference_context} + embed_inputs = [ + _compose_reference_embedding_input( + reference, + context_lookup.get(reference, {}), + target_metadata, + max_chars, + ) + for reference in references + ] + vectors = await embed_texts(embed_inputs) + results: Dict[str, List[Dict[str, Any]]] = {} + for reference, vector in zip(references, vectors): + candidates = store.query(vector, top_k=8) + results[reference] = candidates + return results + + +def _compose_reference_embedding_input( + reference: str, + context_entry: Dict[str, Any], + target_metadata: Dict[str, Any], + max_chars: int, +) -> str: + lines = [reference] + target_standard = target_metadata.get("target_standard") + if target_standard: + lines.append(f"Target standard family: {target_standard}") + paragraphs = context_entry.get("paragraphs") or [] + for paragraph in paragraphs[:2]: + text = paragraph.get("text") + if text: + lines.append(text) + tables = context_entry.get("tables") or [] + if tables: + refs = tables[0].get("references") or [] + if refs: + lines.append("Table references: " + ", ".join(refs)) + text = "\n".join(filter(None, lines)) + if len(text) > max_chars: + text = text[:max_chars] + "...(trimmed)" + return text diff --git a/agents/validation/__init__.py b/agents/validation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4849b85267494d4c09d2f05d63d16c9ed23b644 --- /dev/null +++ b/agents/validation/__init__.py @@ -0,0 +1,3 @@ +from .agent import ValidationAgent + +__all__ = ["ValidationAgent"] diff --git a/agents/validation/agent.py b/agents/validation/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..25f2898232cda7286e88a49ada7c969c575736cb --- /dev/null +++ b/agents/validation/agent.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List + +from ..shared.base import AgentContext, BaseAgent + + +class ValidationAgent(BaseAgent): + name = "validation-agent" + + async def run(self, context: AgentContext) -> Dict[str, Any]: + await self.emit_debug("Running compliance checks.") + + extraction = context.payload.get("extraction_result") or {} + mapping = context.payload.get("mapping_result") or {} + rewrite_plan = context.payload.get("rewrite_plan") or {} + + if not mapping or not rewrite_plan: + return { + "issues": [], + "verdict": "pending", + "notes": "Validation skipped because mapping or rewrite data was unavailable.", + } + + schema = { + "name": "ValidationReport", + "schema": { + "type": "object", + "properties": { + "verdict": { + "type": "string", + "enum": ["approved", "changes_requested", "pending"], + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "severity": { + "type": "string", + "enum": ["info", "low", "medium", "high"], + }, + "related_reference": {"type": "string"}, + }, + "required": ["description", "severity"], + "additionalProperties": False, + }, + "default": [], + }, + "notes": {"type": "string"}, + }, + "required": ["verdict", "issues"], + "additionalProperties": False, + }, + } + + mapping_snippet = json.dumps(mapping.get("mappings", [])[:20], ensure_ascii=False) + rewrite_snippet = json.dumps(rewrite_plan.get("replacements", [])[:20], ensure_ascii=False) + references = extraction.get("references", []) + + messages = [ + { + "role": "system", + "content": ( + "You are a senior structural engineer reviewing a standards migration. " + "Evaluate whether the proposed replacements maintain compliance and highlight any risks." + ), + }, + { + "role": "user", + "content": ( + f"Session: {context.session_id}\n" + f"Detected references: {references}\n" + f"Mappings sample: {mapping_snippet}\n" + f"Rewrite sample: {rewrite_snippet}" + ), + }, + ] + + model = self.settings.openai_model_mapping + + try: + result = await self.call_openai_json(model=model, messages=messages, schema=schema) + await self.emit_debug("Validation agent completed via OpenAI.") + if not result.get("notes"): + result["notes"] = "Review generated by automated validation agent." + return result + except Exception as exc: # noqa: BLE001 + await self.emit_debug(f"Validation agent failed: {exc}") + return { + "issues": [], + "verdict": "pending", + "notes": f"Validation failed: {exc}", + } diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000000000000000000000000000000000000..341121688861594c295816ec788c7a24e495d418 --- /dev/null +++ b/common/README.md @@ -0,0 +1,10 @@ +# Common Domain Assets + +Cross-cutting models, schemas, and events shared between backend services, agents, and workers. + +## Structure + +- `models/` - Core domain entities and DTOs reused across services. +- `utils/` - Reusable helper functions (text normalization, ID generation). +- `schemas/` - JSON schema definitions for agent input/output contracts. +- `events/` - Event payload definitions for pipeline instrumentation. diff --git a/common/embedding_store.py b/common/embedding_store.py new file mode 100644 index 0000000000000000000000000000000000000000..53e9e02091288bf536638e9f1e492f4644108173 --- /dev/null +++ b/common/embedding_store.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, List, Sequence, Tuple + +import numpy as np + + +def _resolve_embedding_root() -> Path: + try: + from server.app.core.config import get_settings # local import to avoid hard dependency at import time + + storage_dir = get_settings().storage_dir + except Exception: # noqa: BLE001 + storage_dir = Path(__file__).resolve().parents[1] / "storage" + return Path(storage_dir) / "embeddings" + + +def get_session_embedding_path(session_id: str) -> Path: + return _resolve_embedding_root() / f"{session_id}.json" + + +@dataclass +class EmbeddingRecord: + vector: List[float] + metadata: dict[str, Any] + + +class EmbeddingStore: + def __init__(self, path: Path) -> None: + self.path = path + self._records: list[EmbeddingRecord] = [] + self._matrix: np.ndarray | None = None + self._load() + + def _load(self) -> None: + if not self.path.exists(): + return + with self.path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + self._records = [EmbeddingRecord(**item) for item in data] + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("w", encoding="utf-8") as fh: + json.dump( + [{"vector": rec.vector, "metadata": rec.metadata} for rec in self._records], + fh, + ensure_ascii=False, + indent=2, + ) + + def clear(self) -> None: + self._records.clear() + self._matrix = None + + def extend(self, vectors: Sequence[Sequence[float]], metadatas: Sequence[dict[str, Any]]) -> None: + for vector, metadata in zip(vectors, metadatas, strict=True): + self._records.append(EmbeddingRecord(list(vector), dict(metadata))) + self._matrix = None + + @property + def is_empty(self) -> bool: + return not self._records + + def _ensure_matrix(self) -> None: + if self._matrix is None and self._records: + self._matrix = np.array([rec.vector for rec in self._records], dtype=np.float32) + + def query(self, vector: Sequence[float], top_k: int = 5) -> List[Tuple[dict[str, Any], float]]: + if self.is_empty: + return [] + self._ensure_matrix() + assert self._matrix is not None + matrix = self._matrix + vec = np.array(vector, dtype=np.float32) + vec_norm = np.linalg.norm(vec) + if not np.isfinite(vec_norm) or vec_norm == 0: + return [] + matrix_norms = np.linalg.norm(matrix, axis=1) + scores = matrix @ vec / (matrix_norms * vec_norm + 1e-12) + top_k = min(top_k, len(scores)) + indices = np.argsort(scores)[::-1][:top_k] + results: List[Tuple[dict[str, Any], float]] = [] + for idx in indices: + score = float(scores[idx]) + metadata = self._records[int(idx)].metadata.copy() + metadata["score"] = score + results.append((metadata, score)) + return results + + def query_many(self, vectors: Sequence[Sequence[float]], top_k: int = 5) -> List[List[dict[str, Any]]]: + return [ + [meta for meta, _ in self.query(vector, top_k=top_k)] + for vector in vectors + ] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5bdfbdb03e1878a8b9ad6516a56c0e44bff8955f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-venv python3-pip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY . /app + +RUN python3 -m venv /app/.venv \ + && /app/.venv/bin/pip install --upgrade pip \ + && /app/.venv/bin/pip install -r server/requirements.txt \ + && npm install --prefix frontend + +ENV PATH="/app/.venv/bin:${PATH}" + +EXPOSE 8000 5173 8765 + +CMD ["python3", "start-rightcodes.py", "--host", "0.0.0.0", "--port", "8765", "--no-browser"] diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..84d35cdbcf899cd112f781634c6bb05dcf729f8a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# Documentation Hub + +Authoritative documentation for architecture, pipelines, agents, and UI workflows. + +## Structure + +- `architecture/` - System diagrams, deployment topology, and context views. +- `agents/` - Detailed specs of each agent, including prompt design and tool APIs. +- `pipelines/` - Pypeflow diagrams, data contracts, and runbooks for each stage. +- `standards/` - Reference material and taxonomy notes for supported codes and standards. +- `ui/` - Wireframes, component inventories, and interaction design specs. +- `specifications/` - Functional requirements, acceptance criteria, and use cases. diff --git a/docs/agents/orchestrator.md b/docs/agents/orchestrator.md new file mode 100644 index 0000000000000000000000000000000000000000..90612eb6904252d4fc5cb3c1eacb0e8b35493796 --- /dev/null +++ b/docs/agents/orchestrator.md @@ -0,0 +1,12 @@ +# Orchestrator Agent (Draft) + +- **Goal:** Coordinate pipeline stages, persist state transitions, and request human intervention when confidence drops below threshold. +- **Inputs:** `document_manifest.json`, latest stage outputs, user session preferences. +- **Outputs:** `progress_state.json`, downstream agent invocation plans, notifications/events. +- **Tool Hooks:** Worker queue enqueuer, storage manifest writer, validation reporter. + +Action items: + +1. Define structured prompt schema and guardrails. +2. Enumerate tool signatures for queueing, status updates, and failure escalation. +3. Align logging with `common/events/` payload definitions. diff --git a/docs/architecture/system-context.md b/docs/architecture/system-context.md new file mode 100644 index 0000000000000000000000000000000000000000..5409aa1f2bf8d8fb5b5045c879da0bf58809b238 --- /dev/null +++ b/docs/architecture/system-context.md @@ -0,0 +1,11 @@ +# System Context (Draft) + +- **Primary Actors:** Engineering consultant (user), Orchestrator Agent, Validation Agent, Export Agent. +- **External Systems:** OpenAI APIs, Object storage (S3-compatible), Auth provider (to be determined). +- **Key Data Stores:** Session manifest store, document blob storage, telemetry pipeline. + +Pending tasks: + +1. Complete C4 level 1 context diagram. +2. Document trust boundaries (uploaded documents vs generated artefacts). +3. Define audit/logging requirements tied to standards compliance. diff --git a/docs/pipelines/pypeflow-overview.md b/docs/pipelines/pypeflow-overview.md new file mode 100644 index 0000000000000000000000000000000000000000..703bb73027ac081539a8ced719502ca36ccc4b0f --- /dev/null +++ b/docs/pipelines/pypeflow-overview.md @@ -0,0 +1,8 @@ +# Pypeflow Overview + +> Placeholder: document the end-to-end job graph once the first pipeline prototype lands. Recommended sections: +> +> 1. High-level mermaid diagram showing ingestion -> extraction -> mapping -> rewrite -> validation -> export. +> 2. Stage-by-stage JSON artefact expectations (input/output schemas). +> 3. Failure handling and retry strategy per stage. +> 4. Hooks for agent overrides and manual approvals. diff --git a/docs/ui/review-workflow.md b/docs/ui/review-workflow.md new file mode 100644 index 0000000000000000000000000000000000000000..c7620c6b84027911080dfa98b58d5d79aa089c87 --- /dev/null +++ b/docs/ui/review-workflow.md @@ -0,0 +1,17 @@ +# Review Workflow (Draft) + +Stages: + +1. Upload wizard captures Word report, one or more standards PDFs, and mapping intent. +2. Diff workspace highlights replaced clauses with inline confidence tags. +3. Validation dashboard lists outstanding checks, comments, and approval history. + +Open questions: + +- How should we present clause-level provenance (link back to PDF page)? +- Do we surface agent rationales verbatim or summarised? +- What accessibility requirements should inform colour coding and indicators? + +UI notes: + +- Show upload progress and pipeline activity log so users know when each processing stage completes. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..e3b0e794cfd00bdfc988be8748951773ecab8528 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000/api diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..caf1360ac065ecf20dd61c21e009cfe746ff173e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,17 @@ +# Frontend Module + +React/TypeScript single-page app responsible for user interaction and review workflows. + +## Structure + +- `public/` - Static assets and HTML shell. +- `src/components/` - Shared UI components (uploaders, diff panels, status widgets). +- `src/pages/` - Route-level containers for upload, review, and export views. +- `src/hooks/` - Reusable logic for API access, session state, and polling. +- `src/layouts/` - Shell layouts (wizard, review workspace). +- `src/state/` - Store configuration (React Query, Zustand, or Redux). +- `src/services/` - API clients, WebSocket connectors, and agent progress handlers. +- `src/utils/` - Formatting helpers, doc diff utilities, and schema transformers. +- `src/types/` - Shared TypeScript declarations. +- `tests/` - Component and integration tests with fixtures and mocks. +- `config/` - Build-time configuration (Vite/Webpack, env samples). diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..bffc5e451f87fa4a8cc5c538c7460f7ff06461e7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + RightCodes + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..44511977d735851dd90dd0f13ddd82198ae3dd10 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2030 @@ +{ + "name": "rightcodes-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rightcodes-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.51.11", + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.3", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.4.5", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.3.tgz", + "integrity": "sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.3.tgz", + "integrity": "sha512-i/LRL6DtuhG6bjGzavIMIVuKKPWx2AnEBIsBfuMm3YoHne0a20nWmsatOCBcVSaT0/8/5YFjNkebHAPLVUSi0Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..14308f2ed04188e463c4d3b148870c33e2de19de --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "rightcodes-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.11", + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.3", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.4.5", + "vite": "^5.4.8" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..8c7cb227dba566a949916446b380e855a6362775 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000000000000000000000000000000000000..bffc5e451f87fa4a8cc5c538c7460f7ff06461e7 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,13 @@ + + + + + + + RightCodes + + +
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ac1d9b10d760c9a037dee49d73ee050314669b8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,34 @@ +import { BrowserRouter, NavLink, Route, Routes } from "react-router-dom"; +import SessionsPage from "./pages/SessionsPage"; +import PresetsPage from "./pages/PresetsPage"; +import PresetEditPage from "./pages/PresetEditPage"; +import ObservatoryPage from "./pages/ObservatoryPage"; + +export default function App() { + return ( + +
+
+

RightCodes Converter

+

Upload reports, review AI-assisted updates, and export revised documents.

+ +
+
+ + } /> + } /> + } /> + } /> + +
+ +
+
+ ); +} diff --git a/frontend/src/components/PresetManager.tsx b/frontend/src/components/PresetManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0017f44ccbd6503c8fc0f3720e419a0bd56d8d50 --- /dev/null +++ b/frontend/src/components/PresetManager.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createPreset, deletePreset, fetchPresets } from "../services/api"; +import type { StandardsPreset } from "../types/session"; + +export default function PresetManager() { + const queryClient = useQueryClient(); + const { data: presets = [], isLoading, isError, error } = useQuery({ + queryKey: ["presets"], + queryFn: fetchPresets, + refetchInterval: (currentPresets) => + Array.isArray(currentPresets) && + currentPresets.some((preset) => preset.status === "processing") + ? 2000 + : false, + }); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [files, setFiles] = useState([]); + + const createPresetMutation = useMutation({ + mutationFn: createPreset, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["presets"] }); + resetForm(); + }, + onError: (err: unknown) => { + alert(`Preset creation failed: ${(err as Error).message}`); + }, + }); + + const deletePresetMutation = useMutation({ + mutationFn: (id: string) => deletePreset(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["presets"] }); + }, + onError: (err: unknown) => { + alert(`Preset deletion failed: ${(err as Error).message}`); + }, + }); + + const resetForm = () => { + setName(""); + setDescription(""); + setFiles([]); + const fileInput = document.getElementById("preset-standards-input") as HTMLInputElement | null; + if (fileInput) { + fileInput.value = ""; + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!files.length) { + alert("Select at least one PDF to build a preset."); + return; + } + createPresetMutation.mutate({ + name, + description: description || undefined, + files, + }); + }; + + const handleDelete = (preset: StandardsPreset) => { + if (!window.confirm(`Delete preset "${preset.name}"? This cannot be undone.`)) { + return; + } + deletePresetMutation.mutate(preset.id); + }; + + return ( +
+

Manage Standards Presets

+

+ Reuse commonly referenced standards without re-uploading them for every session. +

+
+ + + + + {createPresetMutation.isError && ( +

Preset creation failed: {(createPresetMutation.error as Error).message}

+ )} + {createPresetMutation.isSuccess && ( +

+ Preset queued for processing. Progress will appear below shortly. +

+ )} +
+ +
+

Saved Presets

+ {isLoading &&

Loading presets...

} + {isError &&

Failed to load presets: {(error as Error).message}

} + {!isLoading && !presets.length &&

No presets created yet.

} + {presets.length > 0 && ( +
    + {presets.map((preset: StandardsPreset) => { + const totalDocs = preset.total_count || preset.document_count || 0; + const processed = Math.min(preset.processed_count || 0, totalDocs); + const progressPercent = totalDocs + ? Math.min(100, Math.round((processed / totalDocs) * 100)) + : preset.status === "ready" + ? 100 + : 0; + const nextDoc = Math.min(processed + 1, totalDocs); + return ( +
  • +
    +
    + {preset.name} +

    + {preset.document_count} file{preset.document_count === 1 ? "" : "s"} · Updated{" "} + {new Date(preset.updated_at).toLocaleString()} +

    + {preset.description &&

    {preset.description}

    } +
    +
    + {preset.status === "ready" && Ready} + {preset.status === "processing" && ( + + Processing {processed}/{totalDocs} + + )} + {preset.status === "failed" && ( + + Failed{preset.last_error ? `: ${preset.last_error}` : ""} + + )} +
    +
    + {preset.status === "processing" && ( +
    +
    +
    +
    +

    + Currently processing document {nextDoc} of {totalDocs}. +

    +
    + )} +
    + {preset.documents.map((doc) => doc.split(/[/\\]/).pop() ?? doc).join(", ")} +
    +
    + + Edit + + +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/SessionDetails.tsx b/frontend/src/components/SessionDetails.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fee5fa463dab750debb130d4d4b18a8ffd0d3308 --- /dev/null +++ b/frontend/src/components/SessionDetails.tsx @@ -0,0 +1,578 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchSessionById } from "../services/api"; +import type { Session, SessionSummary } from "../types/session"; + +interface DocParse { + path: string; + paragraphs: Array<{ index: number; text: string; style?: string | null; heading_level?: number | null; references?: string[] }>; + tables: Array<{ index: number; rows: string[][]; references?: string[] }>; + summary?: Record; +} + +interface StandardsParseEntry { + path: string; + summary?: Record; + chunks?: Array<{ + page_number: number; + chunk_index: number; + text: string; + heading?: string | null; + clause_numbers?: string[]; + references?: string[]; + is_ocr?: boolean; + }>; +} + +interface ExtractionResult { + document_summary?: string; + sections?: Array<{ paragraph_index: number; text: string; references?: string[] }>; + tables?: Array<{ table_index: number; summary: string; references?: string[] }>; + references?: string[]; + notes?: string; +} + +interface MappingResult { + mappings?: Array<{ + source_reference: string; + source_context?: string; + target_reference: string; + target_clause?: string; + target_summary?: string; + confidence?: number; + rationale?: string; + }>; + unmapped_references?: string[]; + notes?: string; +} + +interface RewritePlan { + replacements?: Array<{ + paragraph_index: number; + original_text: string; + updated_text: string; + applied_mapping: string; + change_reason?: string; + }>; + notes?: string; +} + +interface ValidationReport { + issues?: Array<{ description?: string; severity?: string }>; + verdict?: string; + notes?: string; +} + +interface ExportManifest { + export_path?: string | null; + notes?: string; + replacement_count?: number; +} + +interface SessionDetailsProps { + session: SessionSummary | null; +} + +export default function SessionDetails({ session }: SessionDetailsProps) { + if (!session) { + return
Select a session to inspect its progress.
; + } + + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api"; + const partialSession = session as Partial; + const summaryStandards = Array.isArray(partialSession.standards_docs) + ? [...partialSession.standards_docs] + : []; + const summaryLogs = Array.isArray(partialSession.logs) ? [...partialSession.logs] : []; + const summaryMetadata = + partialSession.metadata && typeof partialSession.metadata === "object" + ? { ...(partialSession.metadata as Record) } + : {}; + const normalizedSummary: Session = { + ...session, + standards_docs: summaryStandards, + logs: summaryLogs, + metadata: summaryMetadata, + }; + const sessionQuery = useQuery({ + queryKey: ["session", session.id], + queryFn: () => fetchSessionById(session.id), + enabled: Boolean(session?.id), + refetchInterval: (data) => + data && ["review", "completed", "failed"].includes(data.status) ? false : 2000, + placeholderData: () => normalizedSummary, + }); + + const latest = sessionQuery.data ?? normalizedSummary; + const standardsDocs = Array.isArray(latest.standards_docs) ? latest.standards_docs : []; + const standardNames = standardsDocs.map((path) => path.split(/[/\\]/).pop() ?? path); + const activityLogs = Array.isArray(latest.logs) ? latest.logs : []; + const inFlight = !["review", "completed", "failed"].includes(latest.status); + + useEffect(() => { + if (sessionQuery.data) { + console.log(`[Session ${sessionQuery.data.id}] status: ${sessionQuery.data.status}`); + } + }, [sessionQuery.data?.id, sessionQuery.data?.status]); + + const metadata = (latest.metadata ?? {}) as Record; + const docParse = metadata.doc_parse as DocParse | undefined; + const standardsParse = metadata.standards_parse as StandardsParseEntry[] | undefined; + const extractionResult = metadata.extraction_result as ExtractionResult | undefined; + const mappingResult = metadata.mapping_result as MappingResult | undefined; + const rewritePlan = metadata.rewrite_plan as RewritePlan | undefined; + const validationReport = metadata.validation_report as ValidationReport | undefined; + const exportManifest = metadata.export_manifest as ExportManifest | undefined; + const exportDownloadUrl = + exportManifest?.export_path !== undefined + ? `${apiBaseUrl}/sessions/${latest.id}/export` + : null; + + const docSummary = docParse?.summary as + | { paragraph_count?: number; table_count?: number; reference_count?: number } + | undefined; + + const standardsProgress = metadata.standards_ingest_progress as + | { + total?: number; + processed?: number; + current_file?: string; + cached_count?: number; + parsed_count?: number; + completed?: boolean; + } + | undefined; + const standardsProgressFile = + standardsProgress?.current_file?.split(/[/\\]/).pop() ?? undefined; + const standardsProgressTotal = + typeof standardsProgress?.total === "number" ? standardsProgress.total : undefined; + const standardsProgressProcessed = + standardsProgressTotal !== undefined + ? Math.min( + typeof standardsProgress?.processed === "number" ? standardsProgress.processed : 0, + standardsProgressTotal + ) + : undefined; + const standardsCachedCount = + typeof standardsProgress?.cached_count === "number" ? standardsProgress.cached_count : undefined; + const standardsParsedCount = + typeof standardsProgress?.parsed_count === "number" ? standardsProgress.parsed_count : undefined; + const pipelineProgress = metadata.pipeline_progress as + | { + total?: number; + current_index?: number; + stage?: string | null; + status?: string; + } + | undefined; + const pipelineStageLabel = pipelineProgress?.stage + ? pipelineProgress.stage + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + : undefined; + const pipelineStageStatus = pipelineProgress?.status + ? pipelineProgress.status.charAt(0).toUpperCase() + pipelineProgress.status.slice(1) + : undefined; + const presetMetadata = Array.isArray(metadata.presets) + ? (metadata.presets as Array<{ id?: string; name?: string | null; documents?: string[] }>) + : []; + const presetNames = presetMetadata + .map((item) => item?.name || item?.id || "") + .filter((value) => Boolean(value)) as string[]; + const presetDocTotal = presetMetadata.reduce( + (acc, item) => acc + (Array.isArray(item?.documents) ? item.documents.length : 0), + 0 + ); + + const [showAllMappings, setShowAllMappings] = useState(false); + const [showAllReplacements, setShowAllReplacements] = useState(false); + + const sections = extractionResult?.sections ?? []; + const sectionsPreview = sections.slice(0, 6); + + const mappingLimit = 6; + const mappings = mappingResult?.mappings ?? []; + const mappingsToDisplay = showAllMappings ? mappings : mappings.slice(0, mappingLimit); + + const replacementLimit = 6; + const replacements = rewritePlan?.replacements ?? []; + const replacementsToDisplay = showAllReplacements ? replacements : replacements.slice(0, replacementLimit); + + const tableReplacements = rewritePlan?.table_replacements ?? []; + const changeLog = rewritePlan?.change_log ?? []; + + return ( +
+
+

Session Overview

+
+ {sessionQuery.isFetching && Updating...} + +
+
+ {inFlight && ( +
+
+
+
+

Processing pipeline... refreshing every 2s.

+ {pipelineProgress?.total ? ( +

+ Pipeline stages:{" "} + {Math.min(pipelineProgress.current_index ?? 0, pipelineProgress.total)} /{" "} + {pipelineProgress.total} + {pipelineStageLabel ? ` — ${pipelineStageLabel}` : ""} + {pipelineStageStatus ? ` (${pipelineStageStatus})` : ""} +

+ ) : null} + {standardsProgressTotal !== undefined ? ( +

+ Standards PDFs: {standardsProgressProcessed ?? 0} / {standardsProgressTotal} + {standardsProgressFile ? ` — ${standardsProgressFile}` : ""} + {standardsCachedCount !== undefined || standardsParsedCount !== undefined ? ( + <> + {" "}(cached {standardsCachedCount ?? 0}, parsed {standardsParsedCount ?? 0}) + + ) : null} +

+ ) : null} +
+ )} +
+
+
Status
+
{latest.status}
+
+
+
Created
+
{new Date(latest.created_at).toLocaleString()}
+
+
+
Updated
+
{new Date(latest.updated_at).toLocaleString()}
+
+
+
Origin
+
{latest.target_standard}
+
+
+
Destination
+
{latest.destination_standard}
+
+ {standardNames.length > 0 && ( +
+
Standards PDFs
+
{standardNames.join(", ")}
+
+ )} + {presetNames.length ? ( +
+
Preset
+
+ {presetNames.join(", ")} + {presetDocTotal ? ` (${presetDocTotal} file${presetDocTotal === 1 ? "" : "s"})` : ""} +
+
+ ) : null} +
+ {latest.status === "review" && ( +
+ Pipeline completed. Review the AI change set and validation notes next. +
+ )} + {latest.last_error && ( +

+ Last error: {latest.last_error} +

+ )} +
+

Activity Log

+ {activityLogs.length ? ( +
    + {activityLogs.map((entry, index) => ( +
  • {entry}
  • + ))} +
+ ) : ( +

No activity recorded yet.

+ )} +
+ + {docParse && ( +
+

Document Parsing

+

+ Analysed {docSummary?.paragraph_count ?? docParse.paragraphs.length} paragraphs and{" "} + {docSummary?.table_count ?? docParse.tables.length} tables. +

+
    + {docParse.paragraphs.slice(0, 5).map((paragraph) => ( +
  • + Paragraph {paragraph.index}: {paragraph.text} + {paragraph.references?.length ? ( + — refs: {paragraph.references.join(", ")} + ) : null} +
  • + ))} +
+
+ )} + + {standardsParse && standardsParse.length > 0 && ( +
+

Standards Corpus

+
    + {standardsParse.slice(0, 4).map((entry) => { + const name = entry.path.split(/[/\\]/).pop() ?? entry.path; + const summary = entry.summary as + | { chunk_count?: number; reference_count?: number; ocr_chunk_count?: number } + | undefined; + const chunkCount = summary?.chunk_count ?? entry.chunks?.length ?? 0; + const ocrChunkCount = + summary?.ocr_chunk_count ?? entry.chunks?.filter((chunk) => chunk.is_ocr)?.length ?? 0; + return ( +
  • + {name} - {chunkCount} chunks analysed + {ocrChunkCount ? ( + + {" "} + ({ocrChunkCount} OCR supplement{ocrChunkCount === 1 ? "" : "s"}) + + ) : null} +
  • + ); + })} +
+
+ )} + + {extractionResult && ( +
+

Extraction Summary

+ {extractionResult.document_summary && ( +

{extractionResult.document_summary}

+ )} + {extractionResult.references && extractionResult.references.length ? ( +

+ References detected: {extractionResult.references.slice(0, 20).join(", ")} + {extractionResult.references.length > 20 ? "..." : ""} +

+ ) : null} + {sectionsPreview.length ? ( +
    + {sectionsPreview.map((section) => ( +
  • + Paragraph {section.paragraph_index}: {section.text} +
  • + ))} +
+ ) : null} + {extractionResult.notes &&

{extractionResult.notes}

} +
+ )} + + {mappingResult && ( +
+

Reference Mapping

+ {mappings.length ? ( + <> +

+ Showing {mappingsToDisplay.length} of {mappings.length} mappings. +

+
    + {mappingsToDisplay.map((mapping, idx) => { + const cappedConfidence = + typeof mapping.confidence === "number" + ? Math.round(Math.min(Math.max(mapping.confidence, 0), 1) * 100) + : null; + return ( +
  • + {mapping.source_reference}{" -> "}{mapping.target_reference} + {cappedConfidence !== null && ( + (confidence {cappedConfidence}%) + )} + {mapping.target_clause && ( +
    Clause: {mapping.target_clause}
    + )} + {mapping.rationale && ( +
    Reason: {mapping.rationale}
    + )} +
  • + ); + })} +
+ {mappings.length > mappingLimit && ( + + )} + + ) : ( +

+ {mappingResult.notes ?? "No mapping actions recorded."} +

+ )} +
+ )} + + {rewritePlan && ( +
+

Rewrite Plan

+ {replacements.length ? ( + <> +

+ Showing {replacementsToDisplay.length} of {replacements.length} replacements. +

+
    + {replacementsToDisplay.map((replacement, idx) => { + const appliedMappings = + Array.isArray(replacement.applied_mappings) && replacement.applied_mappings.length + ? replacement.applied_mappings + : replacement.applied_mapping + ? [replacement.applied_mapping] + : []; + return ( +
  • + Paragraph {replacement.paragraph_index} +
    +
    + Original: {replacement.original_text} +
    +
    + Updated: {replacement.updated_text} +
    + {appliedMappings.length ? ( +
    Mapping: {appliedMappings.join(", ")}
    + ) : null} + {replacement.change_reason && ( +
    Reason: {replacement.change_reason}
    + )} +
    +
  • + ); + })} +
+ {replacements.length > replacementLimit && ( + + )} + + ) : ( +

{rewritePlan.notes ?? "No rewrite actions required."}

+ )} + {tableReplacements.length ? ( +
+

Table Updates

+
    + {tableReplacements.map((table, idx) => { + const appliedMappings = + Array.isArray(table.applied_mappings) && table.applied_mappings.length + ? table.applied_mappings + : table.applied_mapping + ? [table.applied_mapping] + : []; + return ( +
  • + Table {table.table_index} + {appliedMappings.length ? ( +
    Mapping: {appliedMappings.join(", ")}
    + ) : null} + {table.change_reason &&
    {table.change_reason}
    } + {Array.isArray(table.updated_rows) && table.updated_rows.length ? ( +
    Updated rows: {table.updated_rows.length}
    + ) : null} +
  • + ); + })} +
+
+ ) : null} + {changeLog.length ? ( +
+

Change Log

+
    + {changeLog.map((entry, idx) => ( +
  • + {entry.reference}{" -> "}{entry.target_reference} + {entry.affected_paragraphs && entry.affected_paragraphs.length ? ( + {" "}(paragraphs {entry.affected_paragraphs.join(", ")}) + ) : null} + {entry.note &&
    {entry.note}
    } +
  • + ))} +
+
+ ) : null} + {rewritePlan.notes && replacements.length ? ( +

{rewritePlan.notes}

+ ) : null} +
+ )} + + {validationReport && ( +
+

Validation

+ {validationReport.verdict && ( +

+ Verdict: {validationReport.verdict} +

+ )} + {validationReport.issues?.length ? ( +
    + {validationReport.issues.map((issue, idx) => ( +
  • + {issue.description ?? "Issue reported"} + {issue.severity && ({issue.severity})} +
  • + ))} +
+ ) : ( +

No validation issues reported.

+ )} + {validationReport.notes &&

{validationReport.notes}

} +
+ )} + + {exportManifest && ( +
+

Export

+ {exportDownloadUrl ? ( + <> +

+ {exportManifest.notes ?? "Converted document generated using rewrite plan."} +

+ {typeof exportManifest.replacement_count === "number" && ( +

+ {exportManifest.replacement_count} paragraph replacements applied. +

+ )} + + Download DOCX + + + ) : ( +

{exportManifest.notes ?? "Export not yet generated."}

+ )} +
+ )} +
+ ); +} + diff --git a/frontend/src/components/SessionList.tsx b/frontend/src/components/SessionList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8f76c939be4d4f1eb098a350e1c54da0c6c1c43c --- /dev/null +++ b/frontend/src/components/SessionList.tsx @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchSessions } from "../services/api"; +import type { SessionSummary } from "../types/session"; + +interface SessionListProps { + selectedId?: string | null; + onSelect?: (session: SessionSummary) => void; +} + +export default function SessionList({ selectedId, onSelect }: SessionListProps) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["sessions"], + queryFn: fetchSessions, + refetchInterval: 5000, + }); + const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + + if (isLoading) { + return
Loading sessions...
; + } + if (isError) { + return
Failed to load sessions: {(error as Error).message}
; + } + + if (!data?.length) { + return
No sessions yet. Upload a report to get started.
; + } + + return ( +
+

Recent Sessions

+

Auto-refreshing every 5s.

+
    + {data.map((session) => ( +
  • onSelect?.(session)} + > +
    + {session.name} +

    + {session.target_standard} {"->"} {session.destination_standard} +

    +

    + {dateFormatter.format(new Date(session.created_at))} {" · "} + {session.source_doc.split(/[/\\]/).pop() ?? session.source_doc} +

    +
    + {session.status} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/UploadForm.tsx b/frontend/src/components/UploadForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..43f6bbde98db547f4db4127c29a5370197c4addd --- /dev/null +++ b/frontend/src/components/UploadForm.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchPresets, uploadSession } from "../services/api"; +import type { Session, StandardsPreset } from "../types/session"; + +interface UploadFormProps { + onSuccess?: (session: Session) => void; +} + +export default function UploadForm({ onSuccess }: UploadFormProps) { + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [targetStandard, setTargetStandard] = useState(""); + const [destinationStandard, setDestinationStandard] = useState(""); + const [file, setFile] = useState(null); + const [standardsFiles, setStandardsFiles] = useState([]); + const [selectedPresetId, setSelectedPresetId] = useState(""); + + const { data: presets = [], isLoading: presetsLoading, isError: presetsError } = useQuery({ + queryKey: ["presets"], + queryFn: fetchPresets, + refetchInterval: (currentPresets) => + Array.isArray(currentPresets) && + currentPresets.some((preset) => preset.status === "processing") + ? 2000 + : false, + }); + const selectedPreset = presets.find((preset) => preset.id === selectedPresetId); + + const mutation = useMutation({ + mutationFn: uploadSession, + onSuccess: (session) => { + console.log("Session created:", session); + queryClient.invalidateQueries({ queryKey: ["sessions"] }); + onSuccess?.(session); + resetForm(); + }, + onError: (error: unknown) => { + console.error("Session creation failed:", error); + }, + }); + + function resetForm() { + setName(""); + setTargetStandard(""); + setDestinationStandard(""); + setFile(null); + setStandardsFiles([]); + setSelectedPresetId(""); + const reportInput = document.getElementById("source-doc-input") as HTMLInputElement | null; + if (reportInput) { + reportInput.value = ""; + } + const standardsInput = document.getElementById("standards-doc-input") as HTMLInputElement | null; + if (standardsInput) { + standardsInput.value = ""; + } + } + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!file) { + alert("Please select a report to upload."); + return; + } + if (!standardsFiles.length && !selectedPresetId) { + alert("Upload at least one standards PDF or choose a preset bundle."); + return; + } + if (selectedPresetId && selectedPreset && selectedPreset.status !== "ready") { + alert("The selected preset is still processing. Please wait until it is ready."); + return; + } + mutation.mutate({ + name, + target_standard: targetStandard, + destination_standard: destinationStandard, + metadata: {}, + sourceFile: file, + standardsFiles, + standardsPresetId: selectedPresetId || undefined, + }); + }; + + return ( +
+

Create Conversion Session

+ + + + + + + + {mutation.isPending && ( +
+
+
+
+

Uploading files and starting pipeline...

+
+ )} + {mutation.isError && ( +

Upload failed: {(mutation.error as Error).message}

+ )} + {mutation.isSuccess &&

Session created successfully.

} + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d49a2ccdf44fc7964a7af0aebd527692c258a4c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./styles.css"; + +const queryClient = new QueryClient(); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + , +); diff --git a/frontend/src/pages/ObservatoryPage.tsx b/frontend/src/pages/ObservatoryPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b236224f459ea8e63ded4dc897d6f5f1c796116b --- /dev/null +++ b/frontend/src/pages/ObservatoryPage.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchDiagnosticsEvents, fetchDiagnosticsTopology } from "../services/api"; +import type { DiagnosticsEvent, DiagnosticsNode, DiagnosticsTopology } from "../types/diagnostics"; + +const EVENT_PULSE_WINDOW_MS = 5000; +const EVENTS_LIMIT = 60; + +function buildNodeMap(topology?: DiagnosticsTopology): Map { + if (!topology) { + return new Map(); + } + return new Map(topology.nodes.map((node) => [node.id, node])); +} + +function useActiveNodes(events: DiagnosticsEvent[] | undefined, tick: number): Set { + return useMemo(() => { + const active = new Set(); + if (!events) { + return active; + } + const now = Date.now(); + for (const event of events) { + if (!event.node_id) { + continue; + } + const timestamp = Date.parse(event.timestamp); + if (Number.isNaN(timestamp)) { + continue; + } + if (now - timestamp <= EVENT_PULSE_WINDOW_MS) { + active.add(event.node_id); + } + } + return active; + }, [events, tick]); +} + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", +}); + +export default function ObservatoryPage() { + const [timeTick, setTimeTick] = useState(Date.now()); + useEffect(() => { + const id = window.setInterval(() => setTimeTick(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + const { + data: topology, + isLoading: topologyLoading, + isError: topologyError, + error: topologyErrorDetails, + } = useQuery({ + queryKey: ["diagnostics", "topology"], + queryFn: fetchDiagnosticsTopology, + refetchInterval: 15000, + }); + + const { + data: eventsData, + isLoading: eventsLoading, + isError: eventsError, + error: eventsErrorDetails, + } = useQuery({ + queryKey: ["diagnostics", "events"], + queryFn: () => fetchDiagnosticsEvents(EVENTS_LIMIT), + refetchInterval: 2000, + }); + + const nodeMap = buildNodeMap(topology); + const activeNodes = useActiveNodes(eventsData, timeTick); + + const edges = useMemo(() => { + if (!topology) { + return []; + } + return topology.edges + .map((edge) => { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) { + return null; + } + return ( + + ); + }) + .filter(Boolean) as JSX.Element[]; + }, [nodeMap, topology]); + + const nodes = useMemo(() => { + if (!topology) { + return []; + } + return topology.nodes.map((node) => { + const isActive = activeNodes.has(node.id); + return ( + + + + {node.label} + + + ); + }); + }, [activeNodes, topology]); + + return ( +
+
+

System Observatory

+

+ Visual map of key storage locations, services, and external dependencies. Nodes pulse when recent activity + is detected. +

+
+
+
+

Topology

+ {topologyLoading ? ( +

Loading topology...

+ ) : topologyError ? ( +

Failed to load topology: {(topologyErrorDetails as Error).message}

+ ) : ( + + {edges} + {nodes} + + )} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/PresetEditPage.tsx b/frontend/src/pages/PresetEditPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a563c8fc3eb3e33a519daf566e996033db4cd721 --- /dev/null +++ b/frontend/src/pages/PresetEditPage.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { Link, Navigate, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addPresetDocuments, + fetchPresetById, + removePresetDocument, + updatePreset, +} from "../services/api"; +import type { StandardsPreset } from "../types/session"; + +export default function PresetEditPage() { + const { presetId } = useParams<{ presetId: string }>(); + const queryClient = useQueryClient(); + + const { + data: preset, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["preset", presetId], + queryFn: () => fetchPresetById(presetId ?? ""), + enabled: Boolean(presetId), + refetchInterval: (currentPreset) => + currentPreset?.status === "processing" ? 2000 : false, + }); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + useEffect(() => { + if (preset) { + setName(preset.name); + setDescription(preset.description ?? ""); + } + }, [preset?.id, preset?.name, preset?.description]); + + const updateMutation = useMutation({ + mutationFn: (payload: { name?: string; description?: string | null }) => + updatePreset(presetId ?? "", payload), + onSuccess: (updated) => { + queryClient.setQueryData(["preset", presetId], updated); + queryClient.invalidateQueries({ queryKey: ["presets"] }); + }, + onError: (err: unknown) => { + alert(`Failed to update preset: ${(err as Error).message}`); + }, + }); + + const addDocumentsMutation = useMutation({ + mutationFn: (files: File[]) => addPresetDocuments(presetId ?? "", files), + onSuccess: (updated) => { + queryClient.setQueryData(["preset", presetId], updated); + queryClient.invalidateQueries({ queryKey: ["presets"] }); + }, + onError: (err: unknown) => { + alert(`Failed to add documents: ${(err as Error).message}`); + }, + }); + + const removeDocumentMutation = useMutation({ + mutationFn: (documentPath: string) => removePresetDocument(presetId ?? "", documentPath), + onSuccess: (updated) => { + queryClient.setQueryData(["preset", presetId], updated); + queryClient.invalidateQueries({ queryKey: ["presets"] }); + }, + onError: (err: unknown) => { + alert(`Failed to remove document: ${(err as Error).message}`); + }, + }); + + if (!presetId) { + return ; + } + + const totalDocs = preset?.total_count ?? preset?.document_count ?? 0; + const processedDocs = Math.min(preset?.processed_count ?? 0, totalDocs); + const progressPercent = totalDocs + ? Math.min(100, Math.round((processedDocs / totalDocs) * 100)) + : preset?.status === "ready" + ? 100 + : 0; + const nextDoc = Math.min(processedDocs + 1, totalDocs); + + const handleSave = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + alert("Preset name cannot be empty."); + return; + } + updateMutation.mutate({ + name: trimmedName, + description: description.trim() || "", + }); + }; + + const handleAddFiles = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + if (!files.length) { + return; + } + addDocumentsMutation.mutate(files); + event.target.value = ""; + }; + + const handleRemoveDocument = (documentPath: string) => { + if (!window.confirm("Remove this document from the preset?")) { + return; + } + removeDocumentMutation.mutate(documentPath); + }; + + return ( +
+
+
+
+

Edit Preset

+

Update preset metadata and manage the associated PDF files.

+
+ + ← Back to Presets + +
+ + {isLoading &&

Loading preset details...

} + {isError &&

Failed to load preset: {(error as Error).message}

} + {preset && ( + <> +
+ + + +
+ +
+

+ Status: {preset.status} + {preset.status === "processing" && ( + <> + {" "} + · {processedDocs}/{totalDocs} processed + + )} +

+ {preset.last_error &&

Last error: {preset.last_error}

} +
+ {preset.status === "processing" && totalDocs > 0 && ( +
+
+
+
+

+ Currently parsing document {nextDoc} of {totalDocs}. +

+
+ )} + +
+

Documents

+ {preset.documents.length ? ( +
    + {preset.documents.map((doc) => ( +
  • + {doc.split(/[/\\]/).pop() ?? doc} + +
  • + ))} +
+ ) : ( +

No documents attached yet.

+ )} + +
+ + )} +
+
+ ); +} + diff --git a/frontend/src/pages/PresetsPage.tsx b/frontend/src/pages/PresetsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7eca0fa117fadb90597d18e97084fcbc3194c34e --- /dev/null +++ b/frontend/src/pages/PresetsPage.tsx @@ -0,0 +1,9 @@ +import PresetManager from "../components/PresetManager"; + +export default function PresetsPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..acd4ba385681dcfe17d7971a69420449e4571c60 --- /dev/null +++ b/frontend/src/pages/SessionsPage.tsx @@ -0,0 +1,19 @@ +import { useState } from "react"; +import UploadForm from "../components/UploadForm"; +import SessionDetails from "../components/SessionDetails"; +import SessionList from "../components/SessionList"; +import type { Session, SessionSummary } from "../types/session"; + +export default function SessionsPage() { + const [selectedSession, setSelectedSession] = useState(null); + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c0073842326426c2a09dec7e4b175889e0d3b97 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,113 @@ +import axios from "axios"; +import type { + CreatePresetPayload, + CreateSessionPayload, + Session, + SessionStatusResponse, + SessionSummary, + StandardsPreset, + UpdatePresetPayload, +} from "../types/session"; +import type { DiagnosticsEvent, DiagnosticsTopology } from "../types/diagnostics"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api"; + +export async function uploadSession(payload: CreateSessionPayload): Promise { + const formData = new FormData(); + formData.append("name", payload.name); + formData.append("target_standard", payload.target_standard); + formData.append("destination_standard", payload.destination_standard); + formData.append("source_doc", payload.sourceFile); + (payload.standardsFiles ?? []).forEach((file) => { + formData.append("standards_pdfs", file); + }); + if (payload.standardsPresetId) { + formData.append("standards_preset_id", payload.standardsPresetId); + } + if (payload.metadata) { + formData.append("metadata", JSON.stringify(payload.metadata)); + } + + const response = await axios.post(`${API_BASE_URL}/sessions`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; +} + +export async function fetchSessions(): Promise { + const response = await axios.get(`${API_BASE_URL}/sessions`); + return response.data; +} + +export async function fetchSessionById(id: string): Promise { + const response = await axios.get(`${API_BASE_URL}/sessions/${id}`); + return response.data; +} + +export async function fetchSessionStatus(id: string): Promise { + const response = await axios.get(`${API_BASE_URL}/sessions/${id}/status`); + return response.data; +} + +export async function fetchPresets(): Promise { + const response = await axios.get(`${API_BASE_URL}/presets`); + return response.data; +} + +export async function fetchPresetById(id: string): Promise { + const response = await axios.get(`${API_BASE_URL}/presets/${id}`); + return response.data; +} + +export async function createPreset(payload: CreatePresetPayload): Promise { + const formData = new FormData(); + formData.append("name", payload.name); + if (payload.description) { + formData.append("description", payload.description); + } + payload.files.forEach((file) => { + formData.append("standards_pdfs", file); + }); + + const response = await axios.post(`${API_BASE_URL}/presets`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; +} + +export async function addPresetDocuments(id: string, files: File[]): Promise { + const formData = new FormData(); + files.forEach((file) => formData.append("standards_pdfs", file)); + const response = await axios.post(`${API_BASE_URL}/presets/${id}/documents`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; +} + +export async function updatePreset(id: string, payload: UpdatePresetPayload): Promise { + const response = await axios.patch(`${API_BASE_URL}/presets/${id}`, payload); + return response.data; +} + +export async function deletePreset(id: string): Promise { + await axios.delete(`${API_BASE_URL}/presets/${id}`); +} + +export async function removePresetDocument(id: string, documentPath: string): Promise { + const response = await axios.delete(`${API_BASE_URL}/presets/${id}/documents`, { + params: { document: documentPath }, + }); + return response.data; +} + +export async function fetchDiagnosticsTopology(): Promise { + const response = await axios.get(`${API_BASE_URL}/diagnostics/topology`); + return response.data; +} + +export async function fetchDiagnosticsEvents(limit = 50): Promise { + const response = await axios.get<{ events: DiagnosticsEvent[] }>(`${API_BASE_URL}/diagnostics/events`, { + params: { limit }, + }); + return response.data.events; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..90292e28de299e5c9bb606824eae7bc92161afab --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,643 @@ +:root { + color-scheme: light; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + font-weight: 400; + color: #0f172a; + background-color: #f8fafc; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: inherit; +} + +button { + font: inherit; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + padding: 1.5rem; + gap: 1.5rem; +} + +header { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.primary-nav { + display: flex; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.primary-nav a { + color: #1d4ed8; + padding: 0.4rem 0.75rem; + border-radius: 999px; + border: 1px solid transparent; + text-decoration: none; + transition: background 0.2s, color 0.2s, border-color 0.2s; + font-weight: 600; + font-size: 0.9rem; +} + +.primary-nav a:hover { + border-color: #bfdbfe; + background: #e0f2fe; +} + +.primary-nav a.active { + background: #1d4ed8; + color: #ffffff; +} + +main { + flex: 1; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 1.5rem; +} + +.card { + background: #ffffff; + padding: 1.25rem; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-weight: 600; + color: #0f172a; +} + +.field input { + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid #cbd5f5; + font: inherit; +} + +.field select { + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid #cbd5f5; + font: inherit; + background: #ffffff; +} + +button[type="submit"] { + background: #1d4ed8; + color: #ffffff; + border: none; + padding: 0.6rem 1rem; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +button[type="submit"]:disabled { + background: #93c5fd; + cursor: not-allowed; +} + +.error { + color: #b91c1c; +} + +.success { + color: #047857; +} + +.muted { + color: #64748b; + font-size: 0.875rem; +} + +.progress-block { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.progress-bar { + position: relative; + width: 100%; + height: 6px; + background: #dbeafe; + border-radius: 999px; + overflow: hidden; +} + +.progress-bar-fill { + position: absolute; + inset: 0; + background: linear-gradient(90deg, #1d4ed8, #38bdf8); + animation: progress-pulse 1.2s linear infinite; +} + +@keyframes progress-pulse { + 0% { + transform: translateX(-50%); + } + 50% { + transform: translateX(0%); + } + 100% { + transform: translateX(100%); + } +} + +.notice { + padding: 0.75rem; + border-radius: 8px; + border: 1px solid transparent; +} + +.notice.success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #036949; +} + +.log-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.log-panel ul { + margin: 0; + padding-left: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.log-panel li { + font-size: 0.9rem; + color: #0f172a; +} + +.section-card { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.section-card h3 { + margin: 0; + font-size: 1rem; + color: #1e293b; +} + +.section-list { + list-style: disc; + margin: 0; + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.section-list li { + color: #0f172a; +} + +.diff-block { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.35rem; + padding-left: 0.5rem; + border-left: 2px solid #cbd5f5; +} + +.field small { + font-weight: 400; + color: #64748b; +} + +.session-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.session-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.session-list li:hover { + border-color: #bfdbfe; +} + +.session-list li.selected { + border-color: #1d4ed8; + background: #eff6ff; +} + +.status { + text-transform: uppercase; + font-weight: 600; + font-size: 0.75rem; +} + +.status-created, +.status-processing { + color: #1d4ed8; +} + +.status-review { + color: #0f766e; +} + +.status-completed { + color: #16a34a; +} + +.status-failed { + color: #b91c1c; +} + +.details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.details-grid dt { + font-size: 0.75rem; + text-transform: uppercase; + color: #475569; +} + +.details-grid dd { + margin: 0; + font-weight: 600; +} + +footer { + text-align: center; + color: #64748b; + font-size: 0.875rem; +} + +@media (max-width: 960px) { + .grid { + grid-template-columns: 1fr; + } +} +.actions-row { + display: flex; + justify-content: flex-end; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.card-header h2 { + margin: 0; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.text-small { + font-size: 0.875rem; +} + +.ghost-button { + background: transparent; + border: 1px solid #cbd5f5; + color: #1d4ed8; + padding: 0.4rem 0.8rem; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.ghost-button:hover:not(:disabled) { + background: #e0f2fe; +} + +.ghost-button:disabled { + color: #94a3b8; + cursor: not-allowed; +} + +.subsection { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stacked { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.preset-list ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.preset-list li { + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.preset-docs { + font-size: 0.8rem; +} + +.preset-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.ghost-button.danger { + border-color: #fca5a5; + color: #b91c1c; +} + +.ghost-button.danger:hover:not(:disabled) { + background: #fee2e2; +} + +.ghost-button.danger:disabled { + color: #fca5a5; + border-color: #fca5a5; +} + +.edit-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.document-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.document-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.preset-status-card { + margin-top: 1rem; + padding: 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; +} + + +.page-stack { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.preset-header { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.preset-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.status-ready { + color: #16a34a; + font-weight: 600; +} + +.status-processing { + color: #1d4ed8; + font-weight: 600; +} + +.status-failed { + color: #b91c1c; + font-weight: 600; +} + +.linear-progress { + width: 100%; + height: 6px; + border-radius: 999px; + background: #dbeafe; + overflow: hidden; +} + +.linear-progress-fill { + height: 100%; + background: linear-gradient(90deg, #1d4ed8, #38bdf8); + transition: width 0.3s ease; +} + + +/* Observatory */ +.observatory-page { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.observatory-header h2 { + margin: 0; +} + +.observatory-header p { + margin: 0.25rem 0 0; + color: #475569; +} + +.observatory-content { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; +} + +.observatory-graph-card, +.observatory-feed-card { + background: #ffffff; + border-radius: 12px; + padding: 1.25rem; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.08); + flex: 1 1 320px; + min-height: 320px; +} + +.observatory-graph-card { + flex: 2 1 520px; + display: flex; + flex-direction: column; +} + +.observatory-canvas { + margin-top: 1rem; + width: 100%; + height: 100%; + min-height: 320px; + border-radius: 12px; + background: radial-gradient(circle at center, #dbeafe, #e2e8f0 70%); + padding: 1rem; +} + +.observatory-edge { + stroke: rgba(30, 64, 175, 0.35); + stroke-width: 0.8; + stroke-linecap: round; +} + +.observatory-node circle { + transform-origin: center; + transform-box: fill-box; + fill: #1d4ed8; + opacity: 0.7; + transition: r 0.2s ease, opacity 0.2s ease; +} + +.observatory-node text { + font-size: 3px; + fill: #0f172a; + pointer-events: none; +} + +.observatory-node.group-storage circle { + fill: #0ea5e9; +} + +.observatory-node.group-service circle { + fill: #16a34a; +} + +.observatory-node.group-external circle { + fill: #f97316; +} + +.observatory-node circle.pulse { + animation: observatory-pulse 1.2s ease-in-out infinite; + opacity: 0.95; +} + +@keyframes observatory-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.95; + } + 50% { + transform: scale(1.3); + opacity: 0.6; + } +} + +.observatory-feed-card { + display: flex; + flex-direction: column; +} + +.observatory-feed { + list-style: none; + padding: 0; + margin: 1rem 0 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 360px; + overflow-y: auto; +} + +.observatory-feed li { + padding: 0.75rem; + border-radius: 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; +} + +.observatory-feed .feed-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.35rem; +} + +.observatory-feed .feed-row strong { + color: #0f172a; +} + +.observatory-feed .feed-row .muted { + font-size: 0.85rem; +} \ No newline at end of file diff --git a/frontend/src/types/diagnostics.ts b/frontend/src/types/diagnostics.ts new file mode 100644 index 0000000000000000000000000000000000000000..f22b7cfe798d46ec903bf6e1cb418a4029d7fefe --- /dev/null +++ b/frontend/src/types/diagnostics.ts @@ -0,0 +1,27 @@ +export interface DiagnosticsNode { + id: string; + label: string; + group: "storage" | "service" | "external" | string; + position: { x: number; y: number }; + last_event_at?: string | null; +} + +export interface DiagnosticsEdge { + id: string; + source: string; + target: string; +} + +export interface DiagnosticsTopology { + nodes: DiagnosticsNode[]; + edges: DiagnosticsEdge[]; +} + +export interface DiagnosticsEvent { + id: string; + timestamp: string; + event_type: string; + message: string; + node_id?: string | null; + metadata?: Record; +} diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..f228aea4b2cea134ea59d9ad1987f55664677cbb --- /dev/null +++ b/frontend/src/types/session.ts @@ -0,0 +1,59 @@ +export interface SessionSummary { + id: string; + name: string; + status: string; + created_at: string; + updated_at: string; + source_doc: string; + target_standard: string; + destination_standard: string; + standards_count: number; + last_error?: string | null; +} + +export interface Session extends SessionSummary { + standards_docs: string[]; + logs: string[]; + metadata: Record; +} + +export interface CreateSessionPayload { + name: string; + target_standard: string; + destination_standard: string; + metadata?: Record; + sourceFile: File; + standardsFiles?: File[]; + standardsPresetId?: string | null; +} + +export interface SessionStatusResponse { + id: string; + status: string; + updated_at: string; +} + +export interface StandardsPreset { + id: string; + name: string; + description?: string | null; + documents: string[]; + document_count: number; + status: string; + processed_count: number; + total_count: number; + last_error?: string | null; + created_at: string; + updated_at: string; +} + +export interface CreatePresetPayload { + name: string; + description?: string; + files: File[]; +} + +export interface UpdatePresetPayload { + name?: string; + description?: string | null; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..e2ccab7a776ed6ebd81608ef12daac88c5f2ced0 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000000000000000000000000000000000000..9d31e2aed93c876bc048cf2f863cb2a847c901e8 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b178ebf23fd15ddaabcf9ff9ec37089ea598a57 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, + preview: { + port: 4173, + }, +}); diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5ab4715f2496cf08f1d17e7fab0cd154f4c37d27 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,10 @@ +# Infrastructure Assets + +Deployment and operational tooling for local, staging, and production environments. + +## Structure + +- `docker/` - Container definitions, compose files, and local runtime configs. +- `configs/` - Shared configuration overlays (env templates, secrets examples). +- `observability/` - Logging, metrics, and tracing configuration. +- `ci/` - Continuous integration workflows and automation manifests. diff --git a/notes/comments/Initial Start Up.docx b/notes/comments/Initial Start Up.docx new file mode 100644 index 0000000000000000000000000000000000000000..8afa16adb239c62509a7a02a26c2e9d532f3eb55 Binary files /dev/null and b/notes/comments/Initial Start Up.docx differ diff --git a/notes/comments/~$itial Start Up.docx b/notes/comments/~$itial Start Up.docx new file mode 100644 index 0000000000000000000000000000000000000000..cbdcb002a610ca9a222c7472afca89ce6d961ec2 Binary files /dev/null and b/notes/comments/~$itial Start Up.docx differ diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000000000000000000000000000000000..cb4f3b9d6aac5c49244edf2b2f933c7bc4f8f15f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,9 @@ +# Scripts + +Automation helpers for development, QA, and operations. + +## Structure + +- `dev/` - Local environment bootstrap, data loaders, and convenience tasks. +- `tools/` - Linting, formatting, and code generation utilities. +- `ops/` - Deployment, monitoring, and maintenance scripts. diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..1dbfbbbab4b860fe3c9ca2fc4fdaa8c38c7abffc --- /dev/null +++ b/server/.env.example @@ -0,0 +1,12 @@ +RIGHTCODES_APP_NAME=RightCodes Server +RIGHTCODES_DEBUG=true +RIGHTCODES_API_PREFIX=/api +RIGHTCODES_CORS_ORIGINS=["http://localhost:5173"] +RIGHTCODES_STORAGE_DIR=./storage +RIGHTCODES_OPENAI_API_KEY=sk-your-openai-key +RIGHTCODES_OPENAI_API_BASE=https://api.openai.com/v1 +RIGHTCODES_OPENAI_MODEL_EXTRACT=gpt-4.1-mini +RIGHTCODES_OPENAI_MODEL_MAPPING=gpt-4.1-mini +RIGHTCODES_OPENAI_MODEL_REWRITE=gpt-4o-mini +RIGHTCODES_OPENAI_MODEL_VALIDATE=gpt-4.1-mini +RIGHTCODES_OPENAI_MODEL_EMBED=text-embedding-3-large diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9b7e3b82987b6503b8800596d5f5cba039692c8e --- /dev/null +++ b/server/README.md @@ -0,0 +1,18 @@ +# Server Module + +FastAPI service layer coordinating file ingestion, session lifecycle, and agent pipelines. + +## Structure + +- `app/api/routes/` - REST and WebSocket endpoints for uploads, progress, and exports. +- `app/api/schemas/` - Pydantic request and response models. +- `app/api/dependencies/` - Shared dependency injection utilities. +- `app/core/` - Settings, logging, security, and feature flags. +- `app/models/` - ORM entities and persistence abstractions. +- `app/services/` - Business logic for session orchestration and diff management. +- `app/workflows/` - Pypeflow job definitions and coordination logic. +- `app/events/` - Event emitters and subscribers for audit trail and notifications. +- `app/utils/` - Shared helpers (file adapters, serialization, doc merge). +- `scripts/` - Operational scripts (db migrations, data seeding). +- `tests/` - Unit and integration tests (API, workflows, persistence). +- `config/` - Configuration templates (env files, local settings). diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..89f7867bdafdde25dec9e2b5c73037154ff56826 --- /dev/null +++ b/server/app/__init__.py @@ -0,0 +1,11 @@ +from pathlib import Path +import sys + +# Ensure project root is on sys.path so shared packages (e.g., `agents`, `workers`) resolve. +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.append(str(PROJECT_ROOT)) + +from .main import create_app + +__all__ = ["create_app"] diff --git a/server/app/api/dependencies/__init__.py b/server/app/api/dependencies/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ac2e8b6221228322b3f294fdfdff700ea83b307c --- /dev/null +++ b/server/app/api/dependencies/__init__.py @@ -0,0 +1,13 @@ +from .services import ( + get_diagnostics_service, + get_pipeline_orchestrator, + get_preset_service, + get_session_service, +) + +__all__ = [ + "get_session_service", + "get_pipeline_orchestrator", + "get_preset_service", + "get_diagnostics_service", +] diff --git a/server/app/api/dependencies/services.py b/server/app/api/dependencies/services.py new file mode 100644 index 0000000000000000000000000000000000000000..a05ecfb6877f6a7bbef4248a058c2155af6013f7 --- /dev/null +++ b/server/app/api/dependencies/services.py @@ -0,0 +1,35 @@ +from functools import lru_cache + +from ...services import ( + FileCache, + PresetService, + SessionService, + get_diagnostics_service as _get_diagnostics_service, +) +from ...workflows import PipelineOrchestrator, register_default_stages + + +@lru_cache +def get_session_service() -> SessionService: + return SessionService() + + +@lru_cache +def get_file_cache() -> FileCache: + return FileCache() + + +@lru_cache +def get_preset_service() -> PresetService: + return PresetService() + + +@lru_cache +def get_pipeline_orchestrator() -> PipelineOrchestrator: + orchestrator = PipelineOrchestrator(get_session_service()) + return register_default_stages(orchestrator, file_cache=get_file_cache()) + + +@lru_cache +def get_diagnostics_service(): + return _get_diagnostics_service() diff --git a/server/app/api/routes/__init__.py b/server/app/api/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..26284491b7fe8adffd5f50ef117c7c9ca224a0af --- /dev/null +++ b/server/app/api/routes/__init__.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from .diagnostics import router as diagnostics_router +from .health import router as health_router +from .presets import router as presets_router +from .sessions import router as sessions_router + +api_router = APIRouter() +api_router.include_router(health_router, tags=["system"]) +api_router.include_router(sessions_router, prefix="/sessions", tags=["sessions"]) +api_router.include_router(presets_router, prefix="/presets", tags=["presets"]) +api_router.include_router(diagnostics_router, prefix="/diagnostics", tags=["diagnostics"]) + +__all__ = ["api_router"] diff --git a/server/app/api/routes/diagnostics.py b/server/app/api/routes/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..fdd3559954ed5873a8d2004b938c935630921e09 --- /dev/null +++ b/server/app/api/routes/diagnostics.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from ...services.diagnostics_service import DiagnosticsService +from ..dependencies import get_diagnostics_service + +router = APIRouter() + + +@router.get("/topology") +def get_topology( + diagnostics: DiagnosticsService = Depends(get_diagnostics_service), +) -> dict[str, object]: + """Return the static system topology with latest node activity.""" + return diagnostics.get_topology() + + +@router.get("/events") +def get_events( + diagnostics: DiagnosticsService = Depends(get_diagnostics_service), + limit: int = Query(50, ge=1, le=200), + since: Optional[str] = Query(None, description="ISO timestamp to filter events"), +) -> dict[str, object]: + """Return recent diagnostic events.""" + # Validate since value early to provide clearer error messages. + since_value: Optional[str] = None + if since: + try: + datetime.fromisoformat(since) + except ValueError as exc: # pragma: no cover - defensive + raise HTTPException( + status_code=400, detail="Parameter 'since' must be a valid ISO timestamp." + ) from exc + since_value = since + events = diagnostics.get_events(limit=limit, since=since_value) + return {"events": events} diff --git a/server/app/api/routes/health.py b/server/app/api/routes/health.py new file mode 100644 index 0000000000000000000000000000000000000000..4e18d0ccfb524ff01747dc94fa8b369914c8cea2 --- /dev/null +++ b/server/app/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health", summary="Health check") +async def healthcheck() -> dict[str, str]: + return {"status": "ok"} diff --git a/server/app/api/routes/presets.py b/server/app/api/routes/presets.py new file mode 100644 index 0000000000000000000000000000000000000000..5d9c76461496cb3808a6ddae07f24eae38aaa2f1 --- /dev/null +++ b/server/app/api/routes/presets.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import List, Optional + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + Form, + HTTPException, + Query, + Response, + UploadFile, + status, +) + +from ...api.schemas import PresetResponse, PresetUpdateRequest, build_preset_response +from ...services import FileCache, PresetService +from ...utils import save_upload +from ..dependencies.services import get_file_cache, get_preset_service + +router = APIRouter() + + +@router.get("", response_model=list[PresetResponse]) +async def list_presets(preset_service: PresetService = Depends(get_preset_service)) -> list[PresetResponse]: + return [build_preset_response(preset) for preset in preset_service.list_presets()] + + +@router.get("/{preset_id}", response_model=PresetResponse) +async def get_preset( + preset_id: str, + preset_service: PresetService = Depends(get_preset_service), +) -> PresetResponse: + preset = preset_service.get_preset(preset_id) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + return build_preset_response(preset) + + +@router.post("", response_model=PresetResponse, status_code=status.HTTP_201_CREATED) +async def create_preset( + background_tasks: BackgroundTasks, + name: str = Form(...), + description: Optional[str] = Form(None), + standards_pdfs: List[UploadFile] = File(...), + preset_service: PresetService = Depends(get_preset_service), + file_cache: FileCache = Depends(get_file_cache), +) -> PresetResponse: + if not standards_pdfs: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="At least one PDF is required.") + + stored_paths = [] + for upload in standards_pdfs: + stored_paths.append(save_upload(upload.filename, upload.file, subdir="presets/originals")) + + try: + preset = preset_service.start_preset( + name=name, + description=description, + documents=stored_paths, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + background_tasks.add_task(preset_service.process_preset, str(preset.id), file_cache) + + return build_preset_response(preset) + + +@router.post("/{preset_id}/documents", response_model=PresetResponse) +async def add_preset_documents( + preset_id: str, + background_tasks: BackgroundTasks, + standards_pdfs: List[UploadFile] = File(...), + preset_service: PresetService = Depends(get_preset_service), + file_cache: FileCache = Depends(get_file_cache), +) -> PresetResponse: + if not standards_pdfs: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="At least one PDF is required.") + + stored_paths = [ + save_upload(upload.filename, upload.file, subdir="presets/originals") + for upload in standards_pdfs + ] + + preset = preset_service.add_documents(preset_id, stored_paths) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + + if stored_paths: + background_tasks.add_task(preset_service.process_preset, str(preset.id), file_cache) + + return build_preset_response(preset) + + +@router.patch("/{preset_id}", response_model=PresetResponse) +async def update_preset( + preset_id: str, + payload: PresetUpdateRequest, + preset_service: PresetService = Depends(get_preset_service), +) -> PresetResponse: + preset = preset_service.update_preset( + preset_id, + name=payload.name, + description=payload.description, + ) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + return build_preset_response(preset) + + +@router.delete("/{preset_id}/documents", response_model=PresetResponse) +async def remove_preset_document( + preset_id: str, + background_tasks: BackgroundTasks, + document: str = Query(..., description="Path of the document to remove"), + preset_service: PresetService = Depends(get_preset_service), + file_cache: FileCache = Depends(get_file_cache), +) -> PresetResponse: + preset = preset_service.remove_document(preset_id, document) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + if preset.status == "processing" and preset.total_count > 0: + background_tasks.add_task(preset_service.process_preset, str(preset.id), file_cache) + return build_preset_response(preset) + + +@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_preset( + preset_id: str, + preset_service: PresetService = Depends(get_preset_service), +) -> Response: + deleted = preset_service.delete_preset(preset_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/app/api/routes/sessions.py b/server/app/api/routes/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..ad65bd67847b6d32b7c9ba281c1cee5bd45c6ce4 --- /dev/null +++ b/server/app/api/routes/sessions.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import List, Optional + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + Form, + HTTPException, + UploadFile, + status, +) +from fastapi.responses import FileResponse + +from ...api.schemas import ( + SessionResponse, + SessionStatusResponse, + SessionSummaryResponse, + build_session_response, + build_session_summary, +) +from ...services import PresetService, SessionService +from ...utils import save_upload +from ...utils.paths import ( + canonical_storage_path, + resolve_storage_path, + to_storage_relative, +) +from ...workflows import PipelineOrchestrator +from ..dependencies import get_pipeline_orchestrator, get_preset_service, get_session_service + +router = APIRouter() + + +@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED) +async def create_session( + background_tasks: BackgroundTasks, + name: str = Form(...), + target_standard: str = Form(...), + destination_standard: str = Form(...), + metadata: Optional[str] = Form(None), + source_doc: UploadFile = File(...), + standards_pdfs: Optional[List[UploadFile]] = File(None), + standards_preset_id: Optional[str] = Form(None), + session_service: SessionService = Depends(get_session_service), + preset_service: PresetService = Depends(get_preset_service), + orchestrator: PipelineOrchestrator = Depends(get_pipeline_orchestrator), +) -> SessionResponse: + extra_metadata = json.loads(metadata) if metadata else {} + report_path = resolve_storage_path(save_upload(source_doc.filename, source_doc.file)) + standards_paths: list[Path] = [] + if standards_pdfs: + for upload in standards_pdfs: + uploaded_path = save_upload(upload.filename, upload.file) + standards_paths.append(resolve_storage_path(uploaded_path)) + + preset_payloads: list[dict[str, object]] = [] + preset_doc_paths: list[str] = [] + selected_preset = None + if standards_preset_id: + preset = preset_service.get_preset(standards_preset_id) + if not preset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preset not found") + selected_preset = preset + preset_payloads = [dict(payload) for payload in preset.parsed_payloads] + normalised_payloads: list[dict[str, object]] = [] + for payload in preset_payloads: + normalised = dict(payload) + path_value = normalised.get("path") + if path_value: + normalised["path"] = to_storage_relative(path_value) + normalised_payloads.append(normalised) + preset_payloads = normalised_payloads + preset_doc_paths = [to_storage_relative(path) for path in preset.standards_docs] + existing_paths = {canonical_storage_path(path) for path in standards_paths} + for doc_path in preset.standards_docs: + resolved_doc = resolve_storage_path(doc_path) + doc_key = canonical_storage_path(resolved_doc) + if doc_key not in existing_paths: + standards_paths.append(resolved_doc) + existing_paths.add(doc_key) + + if not standards_paths: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide at least one standards PDF or choose a preset.", + ) + + session_metadata = dict(extra_metadata) + if preset_payloads: + session_metadata["preset_standards_payload"] = preset_payloads + session_metadata["preset_standards_doc_paths"] = preset_doc_paths + session_metadata.setdefault("presets", []).append( + { + "id": str(selected_preset.id) if selected_preset else standards_preset_id, + "name": selected_preset.name if selected_preset else None, + "documents": preset_doc_paths, + } + ) + + session = session_service.create_session( + name=name, + source_doc=report_path, + target_standard=target_standard, + destination_standard=destination_standard, + standards_docs=standards_paths, + metadata=session_metadata, + ) + if selected_preset: + session.logs.append( + f"Preset `{selected_preset.name}` applied with {len(preset_doc_paths)} document(s)." + ) + session_service.save_session(session) + background_tasks.add_task(orchestrator.run, session) + return build_session_response(session) + + +@router.get("", response_model=list[SessionSummaryResponse]) +async def list_sessions( + session_service: SessionService = Depends(get_session_service), +) -> list[SessionSummaryResponse]: + sessions = [build_session_summary(session) for session in session_service.list_sessions()] + return sorted(sessions, key=lambda session: session.created_at, reverse=True) + + +@router.get("/{session_id}", response_model=SessionResponse) +async def get_session( + session_id: str, + session_service: SessionService = Depends(get_session_service), +) -> SessionResponse: + session = session_service.get_session(session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return build_session_response(session) + + +@router.get("/{session_id}/status", response_model=SessionStatusResponse) +async def get_session_status( + session_id: str, + session_service: SessionService = Depends(get_session_service), +) -> SessionStatusResponse: + session = session_service.get_session(session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return SessionStatusResponse( + id=session.id, + status=session.status.value, + updated_at=session.updated_at, + ) + + +@router.get("/{session_id}/export", response_class=FileResponse) +async def download_export( + session_id: str, + session_service: SessionService = Depends(get_session_service), +) -> FileResponse: + session = session_service.get_session(session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + manifest = session.metadata.get("export_manifest") if session.metadata else None + if not manifest: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Export not available") + + export_path = manifest.get("export_path") + if not export_path or not Path(export_path).exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Export file missing") + + filename = Path(export_path).name + return FileResponse( + export_path, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + filename=filename, + ) diff --git a/server/app/api/schemas/__init__.py b/server/app/api/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f61782e5430aa09d3a3198c140d6e39152ee0c5a --- /dev/null +++ b/server/app/api/schemas/__init__.py @@ -0,0 +1,23 @@ +from .presets import PresetResponse, PresetUpdateRequest, build_preset_response +from .sessions import ( + SessionCreateRequest, + SessionResponse, + SessionStatusResponse, + SessionSummaryResponse, + build_session_response, + build_session_summary, + session_to_response, +) + +__all__ = [ + "PresetResponse", + "PresetUpdateRequest", + "SessionCreateRequest", + "SessionResponse", + "SessionSummaryResponse", + "SessionStatusResponse", + "build_session_response", + "build_session_summary", + "build_preset_response", + "session_to_response", +] diff --git a/server/app/api/schemas/presets.py b/server/app/api/schemas/presets.py new file mode 100644 index 0000000000000000000000000000000000000000..57bab877fae008185b3bd95c22c0abb8679aeb5b --- /dev/null +++ b/server/app/api/schemas/presets.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from ...models import StandardsPreset +from ...utils.paths import to_storage_relative + + +class PresetResponse(BaseModel): + id: UUID + name: str + description: Optional[str] = None + documents: List[str] = Field(default_factory=list) + document_count: int + status: str + processed_count: int + total_count: int + last_error: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class PresetUpdateRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +def build_preset_response(preset: StandardsPreset) -> PresetResponse: + documents = [to_storage_relative(path) for path in preset.standards_docs] + return PresetResponse( + id=preset.id, + name=preset.name, + description=preset.description, + documents=documents, + document_count=len(documents), + status=preset.status, + processed_count=preset.processed_count, + total_count=preset.total_count or len(documents), + last_error=preset.last_error, + created_at=preset.created_at, + updated_at=preset.updated_at, + ) diff --git a/server/app/api/schemas/sessions.py b/server/app/api/schemas/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..b61a17540a3081600d4e5e49023bf2d9bce188ff --- /dev/null +++ b/server/app/api/schemas/sessions.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from ...utils.paths import to_storage_relative + + +class SessionCreateRequest(BaseModel): + name: str = Field(..., description="Human readable session name") + target_standard: str = Field(..., description="Original standard identifier") + destination_standard: str = Field(..., description="Target standard identifier") + metadata: dict[str, Any] = Field(default_factory=dict) + + +class SessionSummaryResponse(BaseModel): + id: UUID + name: str + status: str + created_at: datetime + updated_at: datetime + source_doc: str + target_standard: str + destination_standard: str + standards_count: int = Field(default=0, description="Number of standards PDFs attached") + last_error: Optional[str] = None + + +class SessionResponse(BaseModel): + id: UUID + name: str + status: str + created_at: datetime + updated_at: datetime + source_doc: str + target_standard: str + destination_standard: str + standards_count: int = Field(default=0, description="Number of standards PDFs attached") + standards_docs: List[str] = Field(default_factory=list) + logs: List[str] = Field(default_factory=list) + metadata: dict[str, Any] + last_error: Optional[str] = None + + +class SessionStatusResponse(BaseModel): + id: UUID + status: str + updated_at: datetime + + +def session_to_response(manifest: dict[str, Any]) -> SessionResponse: + return SessionResponse( + id=manifest["id"], + name=manifest["name"], + status=manifest["status"], + created_at=manifest["created_at"], + updated_at=manifest["updated_at"], + source_doc=to_storage_relative(manifest["source_doc"]), + target_standard=manifest["target_standard"], + destination_standard=manifest["destination_standard"], + standards_count=len(manifest.get("standards_docs", [])), + standards_docs=[to_storage_relative(path) for path in manifest.get("standards_docs", [])], + logs=manifest.get("logs", []), + metadata=manifest["metadata"], + last_error=manifest.get("last_error"), + ) + + +def build_session_response(session) -> SessionResponse: + return SessionResponse( + id=session.id, + name=session.name, + status=session.status.value, + created_at=session.created_at, + updated_at=session.updated_at, + source_doc=to_storage_relative(session.source_doc), + target_standard=session.target_standard, + destination_standard=session.destination_standard, + standards_count=len(session.standards_docs), + standards_docs=[to_storage_relative(path) for path in session.standards_docs], + logs=session.logs, + metadata=session.metadata, + last_error=session.last_error, + ) + + +def build_session_summary(session) -> SessionSummaryResponse: + return SessionSummaryResponse( + id=session.id, + name=session.name, + status=session.status.value, + created_at=session.created_at, + updated_at=session.updated_at, + source_doc=to_storage_relative(session.source_doc), + target_standard=session.target_standard, + destination_standard=session.destination_standard, + standards_count=len(session.standards_docs), + last_error=session.last_error, + ) diff --git a/server/app/core/config.py b/server/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..3a56081362f0ecb2a75e735b3f343f1f05d75d22 --- /dev/null +++ b/server/app/core/config.py @@ -0,0 +1,66 @@ +import logging +from functools import lru_cache +from pathlib import Path +from typing import List, Optional + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +logger = logging.getLogger(__name__) + + +class Settings(BaseSettings): + """Application-wide configuration.""" + + model_config = SettingsConfigDict(env_file=(".env", ".env.local"), env_prefix="RIGHTCODES_") + + app_name: str = "RightCodes Server" + api_prefix: str = "/api" + cors_origins: List[str] = ["http://localhost:5173"] + storage_dir: Path = Field( + default_factory=lambda: (Path(__file__).resolve().parents[3] / "storage") + ) + debug: bool = False + openai_api_key: Optional[str] = None + openai_api_base: str = "https://api.openai.com/v1" + openai_model_extract: str = "gpt-4.1-mini" + openai_model_mapping: str = "gpt-4.1-mini" + openai_model_rewrite: str = "gpt-4o-mini" + openai_model_validate: str = "gpt-4.1-mini" + openai_model_embed: str = "text-embedding-3-large" + + @model_validator(mode="after") + def _normalise_paths(self) -> "Settings": + base_dir = Path(__file__).resolve().parents[3] + storage_dir = self.storage_dir.expanduser() + if not storage_dir.is_absolute(): + storage_dir = (base_dir / storage_dir).resolve() + else: + storage_dir = storage_dir.resolve() + self.storage_dir = storage_dir + self._warn_on_duplicate_storage_root(base_dir) + return self + + def _warn_on_duplicate_storage_root(self, base_dir: Path) -> None: + """Emit a warning if another storage directory is detected inside server/.""" + server_storage = base_dir / "server" / "storage" + if server_storage.resolve() == self.storage_dir: + return + if server_storage.exists(): + try: + if any(server_storage.iterdir()): + logger.warning( + "Detected stale server/storage directory at %s. " + "All runtime artefacts now live under %s. " + "Consider migrating or removing the duplicate to avoid confusion.", + server_storage, + self.storage_dir, + ) + except OSError as exc: # noqa: BLE001 + logger.debug("Unable to inspect legacy storage directory %s: %s", server_storage, exc) + + +@lru_cache +def get_settings() -> Settings: + """Return cached settings instance.""" + return Settings() diff --git a/server/app/core/logging.py b/server/app/core/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..b00024e513ffe071b57b27a2253a7cba671fdd62 --- /dev/null +++ b/server/app/core/logging.py @@ -0,0 +1,27 @@ +import logging +from logging.config import dictConfig + + +def configure_logging(debug: bool = False) -> None: + """Configure application logging.""" + level = "DEBUG" if debug else "INFO" + dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s %(levelname)s [%(name)s] %(message)s", + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "level": level, + } + }, + "root": {"handlers": ["console"], "level": level}, + } + ) + logging.getLogger(__name__).debug("Logging configured (level=%s)", level) diff --git a/server/app/events/__init__.py b/server/app/events/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..74e7e73caf18035b45e13f241a918cf5f13b4a7e --- /dev/null +++ b/server/app/events/__init__.py @@ -0,0 +1,3 @@ +from .notifier import EventNotifier + +__all__ = ["EventNotifier"] diff --git a/server/app/events/notifier.py b/server/app/events/notifier.py new file mode 100644 index 0000000000000000000000000000000000000000..4ed30075755caa015d6618c2a1ea5009b892f24c --- /dev/null +++ b/server/app/events/notifier.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class Event: + name: str + payload: dict + + +EventListener = Callable[[Event], None] + + +class EventNotifier: + """Simple pub/sub placeholder until real event bus is wired.""" + + def __init__(self) -> None: + self._listeners: list[EventListener] = [] + + def connect(self, listener: EventListener) -> None: + self._listeners.append(listener) + + def emit(self, name: str, payload: Optional[dict] = None) -> None: + event = Event(name=name, payload=payload or {}) + logger.debug("Emitting event %s", event.name) + for listener in self._listeners: + listener(event) diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..b4230d12b571e63fb39cab2bc616a978f4059ec2 --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .api.routes import api_router +from .core.config import get_settings +from .core.logging import configure_logging + + +def create_app() -> FastAPI: + settings = get_settings() + configure_logging(settings.debug) + + app = FastAPI( + title=settings.app_name, + version="0.1.0", + docs_url=f"{settings.api_prefix}/docs", + redoc_url=f"{settings.api_prefix}/redoc", + debug=settings.debug, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(api_router, prefix=settings.api_prefix) + return app + + +app = create_app() diff --git a/server/app/models/__init__.py b/server/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5455f8f644623f47eef2ad96af8442fee037864b --- /dev/null +++ b/server/app/models/__init__.py @@ -0,0 +1,4 @@ +from .preset import StandardsPreset +from .session import Session, SessionStatus + +__all__ = ["Session", "SessionStatus", "StandardsPreset"] diff --git a/server/app/models/preset.py b/server/app/models/preset.py new file mode 100644 index 0000000000000000000000000000000000000000..5f1071142e78be2950684b5a3b5d0c765acfff91 --- /dev/null +++ b/server/app/models/preset.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, List, Optional +from uuid import UUID, uuid4 + + +@dataclass() +class StandardsPreset: + """Represents a reusable collection of parsed standards documents.""" + + name: str + standards_docs: list[Path] + parsed_payloads: list[dict[str, Any]] + description: Optional[str] = None + id: UUID = field(default_factory=uuid4) + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + status: str = "ready" + processed_count: int = 0 + total_count: int = 0 + last_error: Optional[str] = None + + def touch(self) -> None: + self.updated_at = datetime.utcnow() diff --git a/server/app/models/session.py b/server/app/models/session.py new file mode 100644 index 0000000000000000000000000000000000000000..17fa22569c058d1b113c446b3e2ef0acdbd9878b --- /dev/null +++ b/server/app/models/session.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Optional +from uuid import UUID, uuid4 + + +class SessionStatus(str, Enum): + CREATED = "created" + PROCESSING = "processing" + REVIEW = "review" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass() +class Session: + """Represents a document conversion session.""" + + name: str + source_doc: Path + target_standard: str + destination_standard: str + standards_docs: list[Path] = field(default_factory=list) + logs: list[str] = field(default_factory=list) + id: UUID = field(default_factory=uuid4) + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + status: SessionStatus = SessionStatus.CREATED + metadata: dict[str, Any] = field(default_factory=dict) + last_error: Optional[str] = None + + def update_status(self, status: SessionStatus, *, error: Optional[str] = None) -> None: + self.status = status + self.updated_at = datetime.utcnow() + self.last_error = error diff --git a/server/app/services/__init__.py b/server/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d263399a83a5c03929b1d39f5755397305d3a1dd --- /dev/null +++ b/server/app/services/__init__.py @@ -0,0 +1,12 @@ +from .diagnostics_service import DiagnosticsService, get_diagnostics_service +from .file_cache import FileCache +from .preset_service import PresetService +from .session_service import SessionService + +__all__ = [ + "SessionService", + "FileCache", + "PresetService", + "DiagnosticsService", + "get_diagnostics_service", +] diff --git a/server/app/services/diagnostics_service.py b/server/app/services/diagnostics_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e4db711ad546a031e03de9af19e2fc7c1c214e06 --- /dev/null +++ b/server/app/services/diagnostics_service.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import lru_cache +from threading import Lock +from typing import Any, Deque, Dict, List, Optional +from uuid import uuid4 + + +@dataclass +class DiagnosticsEvent: + id: str + timestamp: str + event_type: str + message: str + node_id: Optional[str] + metadata: Dict[str, Any] + + +class DiagnosticsService: + """Lightweight in-memory tracker for system topology and recent activity.""" + + _MAX_EVENTS = 256 + + def __init__(self) -> None: + self._lock = Lock() + self._events: Deque[DiagnosticsEvent] = deque(maxlen=self._MAX_EVENTS) + self._node_activity: Dict[str, str] = {} + self._topology = self._build_default_topology() + + def _build_default_topology(self) -> Dict[str, List[Dict[str, Any]]]: + nodes = [ + {"id": "uploads", "label": "Uploads", "group": "storage", "position": {"x": 8, "y": 45}}, + {"id": "sessions", "label": "Sessions", "group": "service", "position": {"x": 25, "y": 25}}, + {"id": "pipeline", "label": "Pipeline", "group": "service", "position": {"x": 45, "y": 25}}, + {"id": "manifests", "label": "Manifests", "group": "storage", "position": {"x": 65, "y": 15}}, + {"id": "embeddings", "label": "Embeddings", "group": "storage", "position": {"x": 65, "y": 35}}, + {"id": "exports", "label": "Exports", "group": "storage", "position": {"x": 85, "y": 45}}, + {"id": "presets", "label": "Presets", "group": "storage", "position": {"x": 25, "y": 45}}, + {"id": "openai", "label": "OpenAI API", "group": "external", "position": {"x": 45, "y": 5}}, + ] + edges = [ + {"id": "uploads->sessions", "source": "uploads", "target": "sessions"}, + {"id": "sessions->pipeline", "source": "sessions", "target": "pipeline"}, + {"id": "presets->pipeline", "source": "presets", "target": "pipeline"}, + {"id": "pipeline->manifests", "source": "pipeline", "target": "manifests"}, + {"id": "pipeline->embeddings", "source": "pipeline", "target": "embeddings"}, + {"id": "pipeline->exports", "source": "pipeline", "target": "exports"}, + {"id": "pipeline->openai", "source": "pipeline", "target": "openai"}, + ] + return {"nodes": nodes, "edges": edges} + + def record_event( + self, + *, + event_type: str, + message: str, + node_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> DiagnosticsEvent: + timestamp = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() + event = DiagnosticsEvent( + id=str(uuid4()), + timestamp=timestamp, + event_type=event_type, + message=message, + node_id=node_id, + metadata=dict(metadata or {}), + ) + with self._lock: + self._events.appendleft(event) + if node_id: + self._node_activity[node_id] = timestamp + return event + + def get_topology(self) -> Dict[str, Any]: + with self._lock: + nodes = [ + { + **node, + "last_event_at": self._node_activity.get(node["id"]), + } + for node in self._topology["nodes"] + ] + return {"nodes": nodes, "edges": list(self._topology["edges"])} + + def get_events(self, *, limit: int = 50, since: Optional[str] = None) -> List[Dict[str, Any]]: + with self._lock: + events = list(self._events) + if since: + try: + since_dt = datetime.fromisoformat(since) + events = [ + event for event in events if datetime.fromisoformat(event.timestamp) >= since_dt + ] + except ValueError: + # Ignore malformed 'since' parameter; return full list instead. + pass + return [ + { + "id": event.id, + "timestamp": event.timestamp, + "event_type": event.event_type, + "message": event.message, + "node_id": event.node_id, + "metadata": event.metadata, + } + for event in events[:limit] + ] + + def clear(self) -> None: + with self._lock: + self._events.clear() + self._node_activity.clear() + + +@lru_cache +def get_diagnostics_service() -> DiagnosticsService: + return DiagnosticsService() diff --git a/server/app/services/file_cache.py b/server/app/services/file_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..7eea5dfc3df1107534c02377377a263d858c51a4 --- /dev/null +++ b/server/app/services/file_cache.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any, Optional + +from ..core.config import get_settings + + +class FileCache: + """Content-addressed cache for expensive file processing results.""" + + def __init__(self, base_dir: Optional[Path] = None) -> None: + settings = get_settings() + cache_root = base_dir or Path(settings.storage_dir) / "cache" / "files" + cache_root.mkdir(parents=True, exist_ok=True) + self._cache_root = cache_root + + def compute_key(self, file_path: Path, namespace: str, *, extra: Optional[str] = None) -> str: + """Return a deterministic key derived from file contents and optional metadata.""" + digest = _hash_file(file_path) + components = [namespace, digest] + if extra: + components.append(extra) + return hashlib.sha256("::".join(components).encode("utf-8")).hexdigest() + + def load(self, namespace: str, key: str) -> Optional[dict[str, Any]]: + """Return cached payload for the given namespace/key pair, if it exists.""" + cache_path = self._cache_path(namespace, key) + if not cache_path.exists(): + return None + with cache_path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + def store(self, namespace: str, key: str, payload: dict[str, Any]) -> None: + """Persist payload to the cache.""" + cache_path = self._cache_path(namespace, key) + cache_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = cache_path.with_suffix(".json.tmp") + temp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + temp_path.replace(cache_path) + + def _cache_path(self, namespace: str, key: str) -> Path: + return self._cache_root / namespace / f"{key}.json" + + +def _hash_file(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() diff --git a/server/app/services/preset_service.py b/server/app/services/preset_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ed4dd5aabf4421894a64aa055e3be7e9fe0d784a --- /dev/null +++ b/server/app/services/preset_service.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional +from uuid import UUID + +from workers.pdf_processing import parse_pdf + +from ..core.config import get_settings +from ..models import StandardsPreset +from ..services.diagnostics_service import get_diagnostics_service +from ..utils.paths import canonical_storage_path, resolve_storage_path, to_storage_relative +from ..workflows.serializers import serialize_pdf_result +from .file_cache import FileCache + + +class PresetService: + """File-backed store for standards presets.""" + + def __init__(self) -> None: + settings = get_settings() + storage_root = Path(settings.storage_dir) / "presets" + storage_root.mkdir(parents=True, exist_ok=True) + self._storage_root = storage_root + self._presets: dict[str, StandardsPreset] = {} + self._progress: Dict[str, Dict[str, Any]] = {} + self._diagnostics = get_diagnostics_service() + self._load_existing_presets() + + def list_presets(self) -> Iterable[StandardsPreset]: + return self._presets.values() + + def get_preset(self, preset_id: str) -> Optional[StandardsPreset]: + preset = self._presets.get(str(preset_id)) + if not preset: + return None + return self._ensure_payloads_loaded(preset) + + def start_preset( + self, + *, + name: str, + documents: List[Path], + description: Optional[str] = None, + ) -> StandardsPreset: + normalized_docs: list[Path] = [] + seen_paths = set() + for doc in documents: + doc_path = resolve_storage_path(doc) + doc_key = self._path_key(doc_path) + if doc_key in seen_paths: + continue + seen_paths.add(doc_key) + normalized_docs.append(doc_path) + + if not normalized_docs: + raise ValueError("Preset requires at least one standards document.") + + preset = StandardsPreset( + name=name, + description=description, + standards_docs=normalized_docs, + parsed_payloads=[], + status="processing", + total_count=len(normalized_docs), + processed_count=0, + last_error=None, + ) + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": 0, + "status": preset.status, + } + self._diagnostics.record_event( + node_id="presets", + event_type="preset.created", + message=f"Preset `{preset.name}` created", + metadata={"preset_id": str(preset.id), "documents": preset.total_count}, + ) + return preset + + def process_preset(self, preset_id: str, file_cache: Optional[FileCache] = None) -> None: + preset = self.get_preset(preset_id) + if not preset: + return + payloads: list[dict[str, Any]] = [] + try: + if not preset.standards_docs: + preset.status = "ready" + preset.total_count = 0 + preset.processed_count = 0 + preset.parsed_payloads = [] + preset.last_error = None + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": 0, + "processed": 0, + "status": preset.status, + } + return + + for index, path in enumerate(preset.standards_docs, start=1): + resolved_path = resolve_storage_path(path) + preset.standards_docs[index - 1] = resolved_path + if not resolved_path.exists(): + raise FileNotFoundError(resolved_path) + payload = self._load_or_parse(resolved_path, file_cache) + payloads.append(payload) + preset.parsed_payloads = payloads.copy() + preset.processed_count = index + preset.status = "processing" + preset.last_error = None + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": preset.processed_count, + "status": preset.status, + } + self._diagnostics.record_event( + node_id="presets", + event_type="preset.process", + message=f"Parsed preset document `{resolved_path.name}`", + metadata={"preset_id": str(preset.id), "path": to_storage_relative(resolved_path)}, + ) + preset.status = "ready" + preset.processed_count = preset.total_count + preset.parsed_payloads = payloads + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": preset.processed_count, + "status": preset.status, + } + preset.parsed_payloads = [] + self._diagnostics.record_event( + node_id="presets", + event_type="preset.ready", + message=f"Preset `{preset.name}` ready", + metadata={"preset_id": str(preset.id)}, + ) + except Exception as exc: # noqa: BLE001 + preset.status = "failed" + preset.last_error = str(exc) + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": preset.processed_count, + "status": preset.status, + "error": str(exc), + } + self._diagnostics.record_event( + node_id="presets", + event_type="preset.failed", + message=f"Preset `{preset.name}` failed: {exc}", + metadata={"preset_id": str(preset.id), "error": str(exc)}, + ) + + def _load_or_parse(self, path: Path, file_cache: Optional[FileCache]) -> dict[str, Any]: + path = resolve_storage_path(path) + payload: Optional[dict[str, Any]] = None + cache_key = None + if file_cache is not None: + cache_key = file_cache.compute_key(path, "standards-parse", extra="max_chunk_chars=1200") + cached = file_cache.load("standards-parse", cache_key) + if cached: + payload = dict(cached) + payload["path"] = to_storage_relative(path) + if payload is None: + result = parse_pdf(path) + payload = serialize_pdf_result(result) + payload["path"] = to_storage_relative(path) + if file_cache is not None and cache_key: + file_cache.store("standards-parse", cache_key, payload) + return payload + + def add_documents(self, preset_id: str, documents: List[Path]) -> Optional[StandardsPreset]: + preset = self._presets.get(str(preset_id)) + if not preset: + return None + existing = {self._path_key(path) for path in preset.standards_docs} + new_paths: list[Path] = [] + for doc in documents: + doc_path = resolve_storage_path(doc) + key = self._path_key(doc_path) + if key in existing: + continue + new_paths.append(doc_path) + existing.add(key) + if not new_paths: + return preset + preset.standards_docs.extend(new_paths) + preset.total_count = len(preset.standards_docs) + preset.processed_count = 0 + preset.status = "processing" + preset.last_error = None + preset.parsed_payloads = [] + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": 0, + "status": preset.status, + } + self._diagnostics.record_event( + node_id="presets", + event_type="preset.updated", + message=f"Added {len(new_paths)} document(s) to preset `{preset.name}`", + metadata={"preset_id": str(preset.id)}, + ) + return preset + + def remove_document(self, preset_id: str, document_path: str) -> Optional[StandardsPreset]: + preset = self._presets.get(str(preset_id)) + if not preset: + return None + document_key = self._path_key(resolve_storage_path(document_path)) + updated_docs = [path for path in preset.standards_docs if self._path_key(path) != document_key] + if len(updated_docs) == len(preset.standards_docs): + return preset + preset.standards_docs = updated_docs + preset.total_count = len(updated_docs) + preset.processed_count = 0 + preset.status = "processing" if updated_docs else "ready" + preset.last_error = None + preset.parsed_payloads = [] + self._save_preset(preset) + self._progress[str(preset.id)] = { + "total": preset.total_count, + "processed": 0, + "status": preset.status, + } + self._diagnostics.record_event( + node_id="presets", + event_type="preset.updated", + message=f"Removed document from preset `{preset.name}`", + metadata={"preset_id": str(preset.id), "path": document_path}, + ) + return preset + + def get_progress(self, preset_id: str) -> Optional[Dict[str, Any]]: + progress = self._progress.get(str(preset_id)) + if progress: + return progress + preset = self.get_preset(preset_id) + if not preset: + return None + return { + "total": preset.total_count, + "processed": preset.processed_count, + "status": preset.status, + "error": preset.last_error, + } + + def update_preset( + self, + preset_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Optional[StandardsPreset]: + preset = self._presets.get(str(preset_id)) + if not preset: + return None + if name is not None: + preset.name = name + if description is not None: + preset.description = description + self._save_preset(preset) + return preset + + def delete_preset(self, preset_id: str) -> bool: + preset = self._presets.pop(str(preset_id), None) + if not preset: + return False + manifest_path = self._storage_root / f"{preset.id}.json" + data_path = self._data_path(preset.id) + if manifest_path.exists(): + manifest_path.unlink() + if data_path.exists(): + data_path.unlink() + self._progress.pop(str(preset.id), None) + return True + + def _save_preset(self, preset: StandardsPreset) -> None: + preset.touch() + self._presets[str(preset.id)] = preset + self._write_manifest(preset) + self._write_payloads(preset) + + def _write_manifest(self, preset: StandardsPreset) -> None: + manifest_path = self._storage_root / f"{preset.id}.json" + payload = { + "id": str(preset.id), + "name": preset.name, + "description": preset.description, + "standards_docs": [to_storage_relative(path) for path in preset.standards_docs], + "created_at": preset.created_at.isoformat(), + "updated_at": preset.updated_at.isoformat(), + "status": preset.status, + "processed_count": preset.processed_count, + "total_count": preset.total_count, + "last_error": preset.last_error, + } + manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + def _load_existing_presets(self) -> None: + for manifest_path in sorted(self._storage_root.glob("*.json")): + try: + raw = manifest_path.read_text(encoding="utf-8") + data = json.loads(raw) + payloads = data.get("parsed_payloads") + preset = StandardsPreset( + id=UUID(data["id"]), + name=data["name"], + description=data.get("description"), + standards_docs=[ + resolve_storage_path(path) for path in data.get("standards_docs", []) + ], + parsed_payloads=list(payloads) if isinstance(payloads, list) else [], + created_at=_parse_datetime(data.get("created_at")), + updated_at=_parse_datetime(data.get("updated_at")), + status=data.get("status", "ready"), + processed_count=int( + data.get( + "processed_count", + len(payloads) if isinstance(payloads, list) else 0, + ) + ), + total_count=int(data.get("total_count", len(data.get("standards_docs", [])))), + last_error=data.get("last_error"), + ) + except Exception: # noqa: BLE001 + continue + self._presets[str(preset.id)] = preset + if preset.parsed_payloads: + self._write_payloads(preset) + preset.parsed_payloads = [] + self._write_manifest(preset) + if preset.status != "ready": + self._progress[str(preset.id)] = { + "total": preset.total_count or len(preset.standards_docs), + "processed": preset.processed_count, + "status": preset.status, + "error": preset.last_error, + } + + def _ensure_payloads_loaded(self, preset: StandardsPreset) -> StandardsPreset: + if preset.parsed_payloads: + return preset + data_path = self._data_path(preset.id) + if data_path.exists(): + try: + raw = data_path.read_text(encoding="utf-8") + data = json.loads(raw) + parsed = data.get("parsed_payloads", []) + if isinstance(parsed, list): + normalised: list[dict[str, Any]] = [] + for entry in parsed: + if not isinstance(entry, dict): + continue + normalised_entry = dict(entry) + path_value = normalised_entry.get("path") + if path_value: + normalised_entry["path"] = to_storage_relative(path_value) + normalised.append(normalised_entry) + preset.parsed_payloads = normalised + except Exception: # noqa: BLE001 + preset.parsed_payloads = [] + return preset + + def _write_payloads(self, preset: StandardsPreset) -> None: + if not preset.parsed_payloads: + return + data_path = self._data_path(preset.id) + if preset.parsed_payloads: + serialisable: list[dict[str, Any]] = [] + for entry in preset.parsed_payloads: + if not isinstance(entry, dict): + continue + normalised_entry = dict(entry) + path_value = normalised_entry.get("path") + if path_value: + normalised_entry["path"] = to_storage_relative(path_value) + serialisable.append(normalised_entry) + if not serialisable: + return + data = {"parsed_payloads": serialisable} + data_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + elif data_path.exists(): + data_path.unlink() + + def _data_path(self, preset_id: UUID | str) -> Path: + return self._storage_root / f"{preset_id}.data.json" + + @staticmethod + def _path_key(path: Path | str) -> str: + return canonical_storage_path(path) + + +def _parse_datetime(value: Optional[str]) -> datetime: + if value is None: + return datetime.utcnow() + try: + return datetime.fromisoformat(value) + except ValueError: + return datetime.utcnow() diff --git a/server/app/services/session_service.py b/server/app/services/session_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e84cbfb87fc4096de9b43d25042ad4e554fa875d --- /dev/null +++ b/server/app/services/session_service.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable, Optional, List +from uuid import UUID +import logging + +from ..core.config import get_settings +from ..models import Session, SessionStatus +from ..services.diagnostics_service import get_diagnostics_service +from ..utils.paths import resolve_storage_path, to_storage_relative + +logger = logging.getLogger(__name__) + + +class SessionService: + """Simple in-memory + file-backed session registry.""" + + def __init__(self) -> None: + settings = get_settings() + storage_root = Path(settings.storage_dir) / "manifests" + storage_root.mkdir(parents=True, exist_ok=True) + self._storage_root = storage_root + self._sessions: dict[str, Session] = {} + self._diagnostics = get_diagnostics_service() + logger.info("SessionService initialized with storage %s", self._storage_root) + self._manifest_index: dict[str, float] = {} + self._load_existing_sessions() + logger.info("SessionService startup loaded %d session(s)", len(self._sessions)) + + def list_sessions(self) -> Iterable[Session]: + self._load_existing_sessions() + return self._sessions.values() + + def get_session(self, session_id: str) -> Optional[Session]: + return self._sessions.get(session_id) + + def create_session( + self, + *, + name: str, + source_doc: Path, + target_standard: str, + destination_standard: str, + standards_docs: Optional[List[Path]] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> Session: + source_doc = resolve_storage_path(source_doc) + normalised_docs = [resolve_storage_path(path) for path in (standards_docs or [])] + session = Session( + name=name, + source_doc=source_doc, + target_standard=target_standard, + destination_standard=destination_standard, + standards_docs=normalised_docs, + metadata=metadata or {}, + ) + session.logs.append("Session created.") + session.logs.append(f"Attached {len(session.standards_docs)} standards PDF(s).") + self.save_session(session) + self._diagnostics.record_event( + node_id="sessions", + event_type="session.created", + message=f"Session `{session.name}` created", + metadata={"session_id": str(session.id)}, + ) + return session + + def update_status( + self, session_id: str, status: SessionStatus, *, error: Optional[str] = None + ) -> Optional[Session]: + session = self._sessions.get(session_id) + if not session: + return None + session.update_status(status, error=error) + session.logs.append(f"Status changed to {session.status.value}.") + self.save_session(session) + self._diagnostics.record_event( + node_id="sessions", + event_type="session.status", + message=f"Session `{session.name}` status -> {session.status.value}", + metadata={"session_id": str(session.id), "status": session.status.value}, + ) + return session + + def save_session(self, session: Session) -> None: + self._sessions[str(session.id)] = session + self._write_manifest(session) + + def store_stage_output(self, session: Session, key: str, value: Any) -> None: + session.metadata[key] = value + self.save_session(session) + + def _write_manifest(self, session: Session) -> None: + manifest_path = self._storage_root / f"{session.id}.json" + metadata = self._normalise_metadata(session.metadata) + session.metadata = metadata + payload = { + "id": str(session.id), + "name": session.name, + "status": session.status.value, + "source_doc": to_storage_relative(session.source_doc), + "target_standard": session.target_standard, + "destination_standard": session.destination_standard, + "standards_docs": [to_storage_relative(path) for path in session.standards_docs], + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + "metadata": metadata, + "last_error": session.last_error, + "logs": session.logs, + } + manifest_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + self._diagnostics.record_event( + node_id="manifests", + event_type="manifest.write", + message=f"Manifest updated for session `{session.name}`", + metadata={"session_id": str(session.id), "path": str(manifest_path)}, + ) + + def _load_existing_sessions(self) -> None: + current_files = {path.stem: path for path in self._storage_root.glob("*.json")} + # remove sessions whose manifest no longer exists + removed_ids = set(self._sessions.keys()) - set(current_files.keys()) + for session_id in removed_ids: + self._sessions.pop(session_id, None) + self._manifest_index.pop(session_id, None) + logger.info("SessionService removed missing session %s", session_id) + + total = 0 + skipped = 0 + for session_id, manifest_path in current_files.items(): + try: + mtime = manifest_path.stat().st_mtime + except OSError as exc: + logger.warning("Unable to stat manifest %s: %s", manifest_path, exc) + continue + + previous_mtime = self._manifest_index.get(session_id) + if previous_mtime is not None and abs(previous_mtime - mtime) < 1e-6: + continue # unchanged + try: + raw = manifest_path.read_text(encoding="utf-8") + data = json.loads(raw) + session = self._session_from_manifest(data) + except Exception as exc: # noqa: BLE001 + skipped += 1 + logger.exception("Failed to load session manifest %s: %s", manifest_path, exc) + continue + self._sessions[str(session.id)] = session + self._manifest_index[str(session.id)] = mtime + total += 1 + logger.info( + "SessionService manifest scan complete: %d refreshed, %d skipped (from %s)", + total, + skipped, + self._storage_root, + ) + + def _session_from_manifest(self, manifest: dict[str, Any]) -> Session: + created_at = datetime.fromisoformat(manifest["created_at"]) + updated_at = datetime.fromisoformat(manifest["updated_at"]) + session = Session( + id=UUID(manifest["id"]), + name=manifest["name"], + source_doc=resolve_storage_path(manifest["source_doc"]), + target_standard=manifest["target_standard"], + destination_standard=manifest["destination_standard"], + standards_docs=[resolve_storage_path(path) for path in manifest.get("standards_docs", [])], + logs=list(manifest.get("logs", [])), + created_at=created_at, + updated_at=updated_at, + status=SessionStatus(manifest["status"]), + metadata=self._normalise_metadata(manifest.get("metadata", {})), + last_error=manifest.get("last_error"), + ) + return session + + @staticmethod + def _normalise_metadata(metadata: Any) -> dict[str, Any]: + if not isinstance(metadata, dict): + return {} + result: dict[str, Any] = dict(metadata) + + doc_parse = result.get("doc_parse") + if isinstance(doc_parse, dict): + doc_copy = dict(doc_parse) + path_value = doc_copy.get("path") + if path_value: + doc_copy["path"] = to_storage_relative(path_value) + result["doc_parse"] = doc_copy + + standards_parse = result.get("standards_parse") + if isinstance(standards_parse, list): + normalised_chunks: list[Any] = [] + for entry in standards_parse: + if isinstance(entry, dict): + entry_copy = dict(entry) + path_value = entry_copy.get("path") + if path_value: + entry_copy["path"] = to_storage_relative(path_value) + normalised_chunks.append(entry_copy) + else: + normalised_chunks.append(entry) + result["standards_parse"] = normalised_chunks + + preset_payloads = result.get("preset_standards_payload") + if isinstance(preset_payloads, list): + normalised_payloads: list[Any] = [] + for entry in preset_payloads: + if isinstance(entry, dict): + entry_copy = dict(entry) + path_value = entry_copy.get("path") + if path_value: + entry_copy["path"] = to_storage_relative(path_value) + normalised_payloads.append(entry_copy) + else: + normalised_payloads.append(entry) + result["preset_standards_payload"] = normalised_payloads + + doc_paths = result.get("preset_standards_doc_paths") + if isinstance(doc_paths, list): + result["preset_standards_doc_paths"] = [to_storage_relative(path) for path in doc_paths] + + presets_meta = result.get("presets") + if isinstance(presets_meta, list): + normalised_presets: list[Any] = [] + for entry in presets_meta: + if isinstance(entry, dict): + entry_copy = dict(entry) + documents = entry_copy.get("documents") + if isinstance(documents, list): + entry_copy["documents"] = [to_storage_relative(path) for path in documents] + normalised_presets.append(entry_copy) + else: + normalised_presets.append(entry) + result["presets"] = normalised_presets + + progress = result.get("standards_ingest_progress") + if isinstance(progress, dict): + progress_copy = dict(progress) + current_file = progress_copy.get("current_file") + if current_file: + progress_copy["current_file"] = to_storage_relative(current_file) + result["standards_ingest_progress"] = progress_copy + + return result diff --git a/server/app/utils/__init__.py b/server/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5ad3fd7b869271412ba7ac7ab8e5d3f02db61fe7 --- /dev/null +++ b/server/app/utils/__init__.py @@ -0,0 +1,9 @@ +from .files import save_upload +from .paths import canonical_storage_path, resolve_storage_path, to_storage_relative + +__all__ = [ + "save_upload", + "canonical_storage_path", + "resolve_storage_path", + "to_storage_relative", +] diff --git a/server/app/utils/files.py b/server/app/utils/files.py new file mode 100644 index 0000000000000000000000000000000000000000..4470a7c8535066927d1306a80d2a06bdf78c8968 --- /dev/null +++ b/server/app/utils/files.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path +from typing import BinaryIO + +from ..core.config import get_settings +from ..services.diagnostics_service import get_diagnostics_service + + +def save_upload(filename: str, fileobj: BinaryIO, *, subdir: str = "originals") -> Path: + """Persist an uploaded file to the storage directory.""" + settings = get_settings() + upload_dir = Path(settings.storage_dir) / subdir + upload_dir.mkdir(parents=True, exist_ok=True) + path = upload_dir / filename + if path.exists(): + stem = path.stem + suffix = path.suffix + counter = 1 + while path.exists(): + path = upload_dir / f"{stem}_{counter}{suffix}" + counter += 1 + with path.open("wb") as buffer: + buffer.write(fileobj.read()) + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="uploads", + event_type="file.saved", + message=f"Stored upload `{path.name}`", + metadata={"path": str(path)}, + ) + return path diff --git a/server/app/utils/paths.py b/server/app/utils/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..c67eab778d0bae7d17f8019f20e56f45a34fbd43 --- /dev/null +++ b/server/app/utils/paths.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from ..core.config import get_settings + + +def resolve_storage_path(path: Path | str) -> Path: + """Return an absolute Path for any file under the storage tree.""" + candidate = Path(path).expanduser() + if candidate.is_absolute(): + return candidate.resolve() + + storage_dir = get_settings().storage_dir + parts = list(candidate.parts) + if parts and parts[0].lower() == "storage": + relative = Path(*parts[1:]) if len(parts) > 1 else Path() + return (storage_dir / relative).resolve() + + base_dir = Path(__file__).resolve().parents[3] + return (base_dir / candidate).resolve() + + +def to_storage_relative(path: Path | str) -> str: + """Render a path relative to the storage root for manifest readability.""" + storage_dir = get_settings().storage_dir + resolved = resolve_storage_path(path) + try: + rel = resolved.relative_to(storage_dir) + return str(Path("storage") / rel) + except ValueError: + return str(resolved) + + +def canonical_storage_path(path: Path | str) -> str: + """Return a canonical string (POSIX) for comparing storage paths.""" + return resolve_storage_path(path).as_posix() diff --git a/server/app/workflows/__init__.py b/server/app/workflows/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..32f12719b4eb63587f3d67954e44b6a115b448c1 --- /dev/null +++ b/server/app/workflows/__init__.py @@ -0,0 +1,8 @@ +from .pipeline import ( + PipelineOrchestrator, + PipelineStage, + PipelineStatus, + register_default_stages, +) + +__all__ = ["PipelineOrchestrator", "PipelineStage", "PipelineStatus", "register_default_stages"] diff --git a/server/app/workflows/embeddings.py b/server/app/workflows/embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..3c5c65004214ef4b7a34047252cad022ba1781fc --- /dev/null +++ b/server/app/workflows/embeddings.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, Dict, List + +from agents.shared.embeddings import embed_texts +from common.embedding_store import EmbeddingStore, get_session_embedding_path +from ..services.diagnostics_service import get_diagnostics_service + +EMBEDDING_BATCH_SIZE = 16 +EMBEDDING_SNIPPET_CHARS = 1500 + + +def index_standards_embeddings(session_id: str, standards_payload: List[Dict[str, Any]]) -> None: + diagnostics = get_diagnostics_service() + diagnostics.record_event( + node_id="embeddings", + event_type="embeddings.start", + message="Building embeddings index", + metadata={"session_id": session_id}, + ) + try: + asyncio.run(_async_index(session_id, standards_payload)) + except Exception as exc: + diagnostics.record_event( + node_id="embeddings", + event_type="embeddings.failed", + message="Embedding indexing failed", + metadata={"session_id": session_id, "error": str(exc)}, + ) + raise + else: + diagnostics.record_event( + node_id="embeddings", + event_type="embeddings.complete", + message="Embeddings index saved", + metadata={"session_id": session_id}, + ) + + +async def _async_index(session_id: str, standards_payload: List[Dict[str, Any]]) -> None: + store = EmbeddingStore(get_session_embedding_path(session_id)) + store.clear() + + buffer_texts: List[str] = [] + buffer_meta: List[Dict[str, Any]] = [] + + for entry in standards_payload: + path = entry.get("path", "") + chunks = entry.get("chunks", []) + for chunk in chunks: + text = chunk.get("text", "") + if not text: + continue + snippet = _build_snippet(chunk, max_chars=EMBEDDING_SNIPPET_CHARS) + buffer_texts.append(snippet) + buffer_meta.append( + { + "path": path, + "document": Path(path).name if path else "unknown", + "heading": chunk.get("heading"), + "clauses": chunk.get("clause_numbers", []), + "page_number": chunk.get("page_number"), + "chunk_index": chunk.get("chunk_index"), + "snippet": text[:600], + } + ) + if len(buffer_texts) >= EMBEDDING_BATCH_SIZE: + vectors = await embed_texts(buffer_texts) + store.extend(vectors, buffer_meta) + buffer_texts, buffer_meta = [], [] + + if buffer_texts: + vectors = await embed_texts(buffer_texts) + store.extend(vectors, buffer_meta) + + store.save() + + +def load_embedding_store(session_id: str) -> EmbeddingStore: + return EmbeddingStore(get_session_embedding_path(session_id)) + + +def _build_snippet(chunk: Dict[str, Any], max_chars: int) -> str: + heading = chunk.get("heading") + clauses = chunk.get("clause_numbers") or [] + text = chunk.get("text", "") + snippet = [] + if heading: + snippet.append(str(heading)) + if clauses: + snippet.append("Clauses: " + ", ".join(clauses[:10])) + snippet.append(text[:max_chars]) + return "\n".join(snippet) diff --git a/server/app/workflows/pipeline.py b/server/app/workflows/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..8e906de0bd8010c3ff517f6d493f86b3f8446b76 --- /dev/null +++ b/server/app/workflows/pipeline.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Awaitable, Callable, Iterable, Optional, Union + +from agents.extraction.agent import ExtractionAgent +from agents.standards_mapping.agent import StandardsMappingAgent +from agents.rewrite.agent import RewriteAgent +from agents.validation.agent import ValidationAgent +from agents.export.agent import ExportAgent +from agents.shared.base import AgentContext +from .embeddings import index_standards_embeddings +from .serializers import serialize_docx_result, serialize_pdf_result +from workers.docx_processing import parse_docx +from workers.pdf_processing import parse_pdf + +from ..models import Session, SessionStatus +from ..services.diagnostics_service import get_diagnostics_service +from ..services.file_cache import FileCache +from ..services.session_service import SessionService +from ..utils.paths import ( + canonical_storage_path, + resolve_storage_path, + to_storage_relative, +) + +logger = logging.getLogger(__name__) + + +class PipelineStage(str, Enum): + INGEST = "ingest" + EXTRACT = "extract" + MAP = "map" + REWRITE = "rewrite" + VALIDATE = "validate" + EXPORT = "export" + + +class PipelineStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +StageHandler = Callable[[Session], Union[Awaitable[None], None]] + + +class PipelineOrchestrator: + """Pipeline orchestrator that runs each stage sequentially.""" + + def __init__(self, session_service: SessionService) -> None: + self._session_service = session_service + self._stages: dict[PipelineStage, StageHandler] = {} + self._diagnostics = get_diagnostics_service() + + def register_stage(self, stage: PipelineStage, handler: StageHandler) -> None: + self._stages[stage] = handler + + def run(self, session: Session, stages: Optional[Iterable[PipelineStage]] = None) -> None: + logger.info("Starting pipeline for session %s", session.id) + self._diagnostics.record_event( + node_id="pipeline", + event_type="pipeline.start", + message=f"Pipeline started for session `{session.name}`", + metadata={"session_id": str(session.id)}, + ) + session.logs.append(f"[{datetime.utcnow().isoformat()}] Pipeline initiated.") + self._session_service.save_session(session) + session = self._session_service.update_status(str(session.id), SessionStatus.PROCESSING) or session + selected_stages = list(stages or self._stages.keys()) + total_stages = len(selected_stages) + if total_stages: + self._session_service.store_stage_output( + session, + "pipeline_progress", + { + "total": total_stages, + "current_index": 0, + "stage": None, + "status": PipelineStatus.PENDING.value, + }, + ) + try: + for index, stage in enumerate(selected_stages, start=1): + handler = self._stages.get(stage) + if not handler: + logger.warning("No handler registered for stage %s", stage) + continue + + logger.debug("Running stage %s for session %s", stage, session.id) + session.logs.append(f"[{datetime.utcnow().isoformat()}] Stage `{stage.value}` started.") + self._diagnostics.record_event( + node_id="pipeline", + event_type="stage.start", + message=f"Stage `{stage.value}` started for session `{session.name}`", + metadata={"session_id": str(session.id), "stage": stage.value}, + ) + if total_stages: + self._session_service.store_stage_output( + session, + "pipeline_progress", + { + "total": total_stages, + "current_index": index, + "stage": stage.value, + "status": PipelineStatus.RUNNING.value, + }, + ) + self._session_service.save_session(session) + + result = handler(session) + if asyncio.iscoroutine(result): + asyncio.run(result) + + session.logs.append(f"[{datetime.utcnow().isoformat()}] Stage `{stage.value}` completed.") + self._diagnostics.record_event( + node_id="pipeline", + event_type="stage.complete", + message=f"Stage `{stage.value}` completed for session `{session.name}`", + metadata={"session_id": str(session.id), "stage": stage.value}, + ) + if total_stages: + self._session_service.store_stage_output( + session, + "pipeline_progress", + { + "total": total_stages, + "current_index": index, + "stage": stage.value, + "status": PipelineStatus.COMPLETED.value, + }, + ) + self._session_service.save_session(session) + + session.logs.append(f"[{datetime.utcnow().isoformat()}] Pipeline completed; awaiting review.") + self._session_service.save_session(session) + self._session_service.update_status(str(session.id), SessionStatus.REVIEW) + self._diagnostics.record_event( + node_id="pipeline", + event_type="pipeline.complete", + message=f"Pipeline completed for session `{session.name}`", + metadata={"session_id": str(session.id)}, + ) + if total_stages: + self._session_service.store_stage_output( + session, + "pipeline_progress", + { + "total": total_stages, + "current_index": total_stages, + "stage": None, + "status": PipelineStatus.COMPLETED.value, + }, + ) + logger.info("Pipeline complete for session %s", session.id) + except Exception as exc: # noqa: BLE001 + logger.exception("Pipeline failed for session %s", session.id) + session.logs.append(f"[{datetime.utcnow().isoformat()}] Pipeline failed: {exc}.") + self._session_service.save_session(session) + self._session_service.update_status( + str(session.id), + SessionStatus.FAILED, + error=str(exc), + ) + self._diagnostics.record_event( + node_id="pipeline", + event_type="pipeline.failed", + message=f"Pipeline failed for session `{session.name}`", + metadata={"session_id": str(session.id), "error": str(exc)}, + ) + if total_stages: + self._session_service.store_stage_output( + session, + "pipeline_progress", + { + "total": total_stages, + "current_index": min(len(selected_stages), total_stages), + "stage": None, + "status": PipelineStatus.FAILED.value, + }, + ) + + +def register_default_stages( + orchestrator: PipelineOrchestrator, + *, + file_cache: FileCache, +) -> PipelineOrchestrator: + """Register functional ingestion, extraction, mapping, rewrite, and export stages.""" + + session_service = orchestrator._session_service # noqa: SLF001 + diagnostics = get_diagnostics_service() + + def _path_key_variants(raw_path: str) -> set[str]: + variants = {raw_path} + try: + path_obj = Path(raw_path) + variants.add(str(path_obj)) + variants.add(path_obj.as_posix()) + variants.add(canonical_storage_path(path_obj)) + except Exception: # noqa: BLE001 + pass + return {variant for variant in variants if variant} + + def ingest_stage(session: Session) -> None: + doc_path = resolve_storage_path(session.source_doc) + doc_cache_key = file_cache.compute_key(doc_path, "doc-parse") + cached_doc_payload = file_cache.load("doc-parse", doc_cache_key) + if cached_doc_payload: + doc_payload = dict(cached_doc_payload) + doc_payload["path"] = to_storage_relative(doc_path) + session.logs.append( + f"[{datetime.utcnow().isoformat()}] Loaded cached report parse " + f"({len(doc_payload.get('paragraphs', []))} paragraphs, " + f"{len(doc_payload.get('tables', []))} tables)." + ) + diagnostics.record_event( + node_id="pipeline", + event_type="doc.cache", + message=f"Loaded cached report parse for session `{session.name}`", + metadata={ + "session_id": str(session.id), + "path": to_storage_relative(doc_path), + }, + ) + else: + doc_result = parse_docx(doc_path) + doc_payload = serialize_docx_result(doc_result) + file_cache.store("doc-parse", doc_cache_key, doc_payload) + session.logs.append( + f"[{datetime.utcnow().isoformat()}] Parsed report ({len(doc_payload['paragraphs'])} paragraphs, " + f"{len(doc_payload['tables'])} tables)." + ) + diagnostics.record_event( + node_id="pipeline", + event_type="doc.parse", + message=f"Parsed report for session `{session.name}`", + metadata={ + "session_id": str(session.id), + "path": to_storage_relative(doc_path), + }, + ) + session_service.store_stage_output(session, "doc_parse", doc_payload) + + standards_payload: list[dict[str, Any]] = [] + total_standards = len(session.standards_docs) + cached_count = 0 + parsed_count = 0 + + raw_preset_payloads = session.metadata.get("preset_standards_payload") or [] + preset_payload_map: dict[str, dict[str, Any]] = {} + for entry in raw_preset_payloads: + if not isinstance(entry, dict): + continue + path_str = entry.get("path") + if not path_str: + continue + normalised_entry = dict(entry) + normalised_entry["path"] = to_storage_relative(path_str) + canonical_key = canonical_storage_path(path_str) + preset_payload_map[canonical_key] = normalised_entry + for key in _path_key_variants(path_str): + preset_payload_map.setdefault(key, normalised_entry) + if total_standards: + session_service.store_stage_output( + session, + "standards_ingest_progress", + { + "total": total_standards, + "processed": 0, + "cached_count": 0, + "parsed_count": 0, + }, + ) + + for index, path in enumerate(session.standards_docs, start=1): + resolved_path = resolve_storage_path(path) + path_str = to_storage_relative(resolved_path) + payload: Optional[dict[str, Any]] = None + canonical = canonical_storage_path(resolved_path) + preset_payload = ( + preset_payload_map.get(canonical) + or preset_payload_map.get(path_str) + or preset_payload_map.get(Path(path_str).as_posix()) + ) + cache_key = file_cache.compute_key( + resolved_path, + "standards-parse", + extra="max_chunk_chars=1200", + ) + + if preset_payload: + payload = dict(preset_payload) + payload["path"] = path_str + cached_count += 1 + file_cache.store("standards-parse", cache_key, payload) + diagnostics.record_event( + node_id="presets", + event_type="preset.cache", + message=f"Used cached preset parse for `{Path(path_str).name}`", + metadata={ + "session_id": str(session.id), + "path": path_str, + }, + ) + else: + cached_payload = file_cache.load("standards-parse", cache_key) + if cached_payload: + payload = dict(cached_payload) + payload["path"] = to_storage_relative(cached_payload.get("path", resolved_path)) + cached_count += 1 + diagnostics.record_event( + node_id="pipeline", + event_type="standards.cache", + message=f"Loaded cached standards parse for `{Path(path_str).name}`", + metadata={ + "session_id": str(session.id), + "path": path_str, + }, + ) + else: + result = parse_pdf(resolved_path) + payload = serialize_pdf_result(result) + file_cache.store("standards-parse", cache_key, payload) + parsed_count += 1 + diagnostics.record_event( + node_id="pipeline", + event_type="standards.parse", + message=f"Parsed standards document `{Path(path_str).name}`", + metadata={ + "session_id": str(session.id), + "path": path_str, + }, + ) + payload["path"] = path_str + standards_payload.append(payload) + + if total_standards: + session_service.store_stage_output( + session, + "standards_ingest_progress", + { + "total": total_standards, + "processed": index, + "current_file": path_str, + "cached_count": cached_count, + "parsed_count": parsed_count, + }, + ) + + session.logs.append( + f"[{datetime.utcnow().isoformat()}] Ingested {len(standards_payload)} standards PDF(s) " + f"(parsed {parsed_count}, cached {cached_count})." + ) + diagnostics.record_event( + node_id="pipeline", + event_type="ingest.complete", + message=f"Ingested {len(standards_payload)} standards for session `{session.name}`", + metadata={ + "session_id": str(session.id), + "parsed": parsed_count, + "cached": cached_count, + }, + ) + session_service.store_stage_output(session, "standards_parse", standards_payload) + if total_standards: + session_service.store_stage_output( + session, + "standards_ingest_progress", + { + "total": total_standards, + "processed": total_standards, + "cached_count": cached_count, + "parsed_count": parsed_count, + "completed": True, + }, + ) + try: + index_standards_embeddings(str(session.id), standards_payload) + except Exception as exc: # noqa: BLE001 + logger.warning("Embedding indexing failed for session %s: %s", session.id, exc) + session.logs.append(f"[{datetime.utcnow().isoformat()}] Embedding indexing failed: {exc}.") + + + async def extraction_stage(session: Session) -> None: + doc_payload = session.metadata.get("doc_parse", {}) or {} + paragraphs = doc_payload.get("paragraphs", []) + tables = doc_payload.get("tables", []) + metadata = { + "paragraph_count": len(paragraphs), + "table_count": len(tables), + } + + agent = ExtractionAgent() + context = AgentContext( + session_id=str(session.id), + payload={ + "paragraphs": paragraphs, + "tables": tables, + "metadata": metadata, + }, + ) + result = await agent.run(context) + session_service.store_stage_output(session, "extraction_result", result) + + async def mapping_stage(session: Session) -> None: + extraction_result = session.metadata.get("extraction_result") or {} + standards_payload = session.metadata.get("standards_parse") or [] + + standards_chunks: list[dict[str, Any]] = [] + for entry in standards_payload: + chunks = entry.get("chunks", []) + standards_chunks.extend(chunks) + + agent = StandardsMappingAgent() + context = AgentContext( + session_id=str(session.id), + payload={ + "extraction_result": extraction_result, + "standards_chunks": standards_chunks, + "target_metadata": { + "target_standard": session.destination_standard, + "standards_count": len(standards_payload), + }, + }, + ) + result = await agent.run(context) + session_service.store_stage_output(session, "mapping_result", result) + + async def rewrite_stage(session: Session) -> None: + mapping_result = session.metadata.get("mapping_result") or {} + extraction_result = session.metadata.get("extraction_result") or {} + doc_payload = session.metadata.get("doc_parse") or {} + + agent = RewriteAgent() + context = AgentContext( + session_id=str(session.id), + payload={ + "mapping_result": mapping_result, + "document_sections": extraction_result.get("sections", []), + "document_paragraphs": doc_payload.get("paragraphs", []), + "document_tables": doc_payload.get("tables", []), + "target_voice": "Professional engineering tone", + "constraints": [ + "Do not alter calculations or numeric values.", + "Preserve paragraph numbering and formatting markers.", + ], + }, + ) + result = await agent.run(context) + session_service.store_stage_output(session, "rewrite_plan", result) + + async def validate_stage(session: Session) -> None: + # Placeholder validation using existing agent scaffold. + agent = ValidationAgent() + context = AgentContext( + session_id=str(session.id), + payload={ + "extraction_result": session.metadata.get("extraction_result"), + "mapping_result": session.metadata.get("mapping_result"), + "rewrite_plan": session.metadata.get("rewrite_plan"), + }, + ) + result = await agent.run(context) + session_service.store_stage_output(session, "validation_report", result) + + async def export_stage(session: Session) -> None: + agent = ExportAgent() + context = AgentContext( + session_id=str(session.id), + payload={ + "rewrite_plan": session.metadata.get("rewrite_plan"), + "original_document": session.metadata.get("doc_parse"), + "original_path": str(session.source_doc), + }, + ) + result = await agent.run(context) + session_service.store_stage_output(session, "export_manifest", result) + + orchestrator.register_stage(PipelineStage.INGEST, ingest_stage) + orchestrator.register_stage(PipelineStage.EXTRACT, extraction_stage) + orchestrator.register_stage(PipelineStage.MAP, mapping_stage) + orchestrator.register_stage(PipelineStage.REWRITE, rewrite_stage) + orchestrator.register_stage(PipelineStage.VALIDATE, validate_stage) + orchestrator.register_stage(PipelineStage.EXPORT, export_stage) + return orchestrator + + diff --git a/server/app/workflows/serializers.py b/server/app/workflows/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..7be884f4d1cdfbc577cdad2400f23694210de4d0 --- /dev/null +++ b/server/app/workflows/serializers.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any, Dict + +from workers.docx_processing.parser import DocxParseResult +from workers.pdf_processing.parser import PdfParseResult + +from ..utils.paths import to_storage_relative + + +def serialize_docx_result(result: DocxParseResult) -> dict[str, Any]: + return { + "path": to_storage_relative(result.path), + "paragraphs": [ + { + "index": block.index, + "text": block.text, + "style": block.style, + "heading_level": block.heading_level, + "references": block.references, + } + for block in result.paragraphs + ], + "tables": [ + { + "index": block.index, + "rows": block.rows, + "references": block.references, + } + for block in result.tables + ], + "summary": { + "paragraph_count": len(result.paragraphs), + "table_count": len(result.tables), + "reference_count": len( + { + ref + for block in result.paragraphs + for ref in block.references + }.union( + { + ref + for block in result.tables + for ref in block.references + } + ) + ), + }, + } + + +def serialize_pdf_result(result: PdfParseResult) -> dict[str, Any]: + payload: Dict[str, Any] = { + "path": to_storage_relative(result.path), + "chunks": [ + { + "page_number": chunk.page_number, + "chunk_index": chunk.chunk_index, + "text": chunk.text, + "heading": chunk.heading, + "clause_numbers": chunk.clause_numbers, + "references": chunk.references, + "is_ocr": chunk.is_ocr, + } + for chunk in result.chunks + ], + "summary": { + "chunk_count": len(result.chunks), + "reference_count": len( + { + ref + for chunk in result.chunks + for ref in chunk.references + } + ), + }, + } + if result.ocr_pages: + payload["summary"]["ocr_pages"] = result.ocr_pages + payload["summary"]["ocr_chunk_count"] = len( + [chunk for chunk in result.chunks if chunk.is_ocr] + ) + return payload diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..366b5a4633d074239cc0ae2f579c6feb4c1d00a0 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115.0,<1.0.0 +uvicorn[standard]>=0.30.0,<0.32.0 +pydantic-settings>=2.4.0,<3.0.0 +python-multipart>=0.0.9,<0.1.0 +aiofiles>=24.0.0,<25.0.0 +httpx>=0.27.0,<0.28.0 +python-docx>=1.1.0,<1.2.0 +pdfplumber>=0.11.4,<0.12.0 +openai>=1.45.0,<2.0.0 +numpy>=1.26.0,<2.0.0 +pillow>=10.4.0,<11.0.0 +pytesseract>=0.3.10,<0.4.0 diff --git a/start-rightcodes.bat b/start-rightcodes.bat new file mode 100644 index 0000000000000000000000000000000000000000..41a7bd8913eab9a422b2cb1bb954d19521b7386d --- /dev/null +++ b/start-rightcodes.bat @@ -0,0 +1,63 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +set "EXIT_CODE=1" +set "PYTHON_EXE=" +set "NPM_EXE=" + +for %%P in (python.exe py.exe python) do ( + where %%P >nul 2>&1 + if not errorlevel 1 ( + set "PYTHON_EXE=%%P" + goto :found_python + ) +) + +echo [ERROR] Python 3.8+ is required but was not found in PATH. +echo Download and install it from https://www.python.org/downloads/ then reopen this window. +goto :abort + +:found_python +for %%N in (npm.cmd npm.exe npm) do ( + where %%N >nul 2>&1 + if not errorlevel 1 ( + set "NPM_EXE=%%N" + goto :found_npm + ) +) + +echo [ERROR] Node.js (npm) is required but was not found in PATH. +echo Download it from https://nodejs.org/en/download/ (npm ships with Node.js) then reopen this window. +goto :abort + +:found_npm +"%PYTHON_EXE%" -c "import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)" >nul 2>&1 +if errorlevel 1 ( + for /f "tokens=1-3" %%A in ('"%PYTHON_EXE%" --version 2^>^&1') do set "PY_VER=%%A %%B %%C" + echo [ERROR] Detected Python %PY_VER%. Python 3.8 or newer is required. + goto :abort +) + +echo [INFO] Using Python: %PYTHON_EXE% +echo [INFO] Using npm: %NPM_EXE% +echo [INFO] First launch downloads dependencies; ensure this machine has internet access. + +pushd "%SCRIPT_DIR%" +"%PYTHON_EXE%" "%SCRIPT_DIR%start-rightcodes.py" %* +set "EXIT_CODE=%ERRORLEVEL%" +popd + +if "%EXIT_CODE%"=="0" ( + endlocal + exit /b 0 +) + +echo. +echo Launcher exited with error code %EXIT_CODE%. +echo Review the console output above for details. + +:abort +pause +endlocal +exit /b %EXIT_CODE% diff --git a/start-rightcodes.html b/start-rightcodes.html new file mode 100644 index 0000000000000000000000000000000000000000..87c79d038a433d82c4f039762c0ec725ac4801d9 --- /dev/null +++ b/start-rightcodes.html @@ -0,0 +1,326 @@ + + + + + RightCodes Launcher + + + + +
+

RightCodes Launcher

+ +
+

Start Services

+

Enter your OpenAI API key to launch both the backend (uvicorn) and the frontend (npm run dev) in dedicated consoles.

+
+ + +
+ + + +
+
+
+
+ +
+

Environment Status

+
Loading status...
+
+
+ +
+

Setup Log

+
Collecting setup log...
+
+
+ + + + diff --git a/start-rightcodes.py b/start-rightcodes.py new file mode 100644 index 0000000000000000000000000000000000000000..013de0f54993ba006318d82e1e80afa0f3d4b2a7 --- /dev/null +++ b/start-rightcodes.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +"""RightCodes launcher with a lightweight HTML control panel.""" + +from __future__ import annotations + +import argparse +import atexit +import datetime as dt +import http.server +import json +import os +import pathlib +import shutil +import subprocess +import sys +import signal +import threading +import time +import urllib.parse +import webbrowser +from http import HTTPStatus +from typing import Dict, List, Optional + + +REPO_DIR = pathlib.Path(__file__).resolve().parent +FRONTEND_DIR = REPO_DIR / "frontend" +VENV_DIR = REPO_DIR / ".venv" +BACKEND_REQUIREMENTS = REPO_DIR / "server" / "requirements.txt" +LAUNCHER_HTML_PATH = REPO_DIR / "start-rightcodes.html" +TIME_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def _is_windows() -> bool: + return os.name == "nt" + + +def _venv_python() -> pathlib.Path: + if _is_windows(): + return VENV_DIR / "Scripts" / "python.exe" + return VENV_DIR / "bin" / "python" + + +def _activate_snippet() -> str: + if _is_windows(): + return f'call "{VENV_DIR / "Scripts" / "activate.bat"}"' + return f'source "{VENV_DIR / "bin" / "activate"}"' + + +def _timestamp() -> str: + return dt.datetime.now().strftime(TIME_FORMAT) + + +class LauncherState: + """Mutable state container shared between the launcher runtime and HTTP handler.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.backend_proc: Optional[subprocess.Popen] = None + self.frontend_proc: Optional[subprocess.Popen] = None + self.openai_key: Optional[str] = None + self.messages: List[str] = [] + self.errors: List[str] = [] + self.prepared: bool = False + self.ui_opened: bool = False + self.dashboard_opened: bool = False + self.python_path: Optional[str] = None + self.npm_path: Optional[str] = None + + def add_message(self, message: str) -> None: + entry = f"[{_timestamp()}] {message}" + print(entry) + with self.lock: + self.messages.append(entry) + + def add_error(self, message: str) -> None: + entry = f"[{_timestamp()}] ERROR: {message}" + print(entry) + with self.lock: + self.errors.append(entry) + + def record_backend(self, proc: subprocess.Popen) -> None: + with self.lock: + self.backend_proc = proc + + def record_frontend(self, proc: subprocess.Popen) -> None: + with self.lock: + self.frontend_proc = proc + + def set_openai_key(self, value: Optional[str]) -> None: + with self.lock: + self.openai_key = value + + def set_prepared(self, value: bool) -> None: + with self.lock: + self.prepared = value + + def mark_ui_opened(self) -> bool: + with self.lock: + if self.ui_opened: + return False + self.ui_opened = True + return True + + def mark_dashboard_opened(self) -> bool: + with self.lock: + if self.dashboard_opened: + return False + self.dashboard_opened = True + return True + + def detach_processes(self) -> Dict[str, Optional[subprocess.Popen]]: + with self.lock: + backend = self.backend_proc + frontend = self.frontend_proc + self.backend_proc = None + self.frontend_proc = None + self.openai_key = None + return {"backend": backend, "frontend": frontend} + + def get_status(self) -> Dict[str, object]: + with self.lock: + backend_info = _process_info(self.backend_proc) + frontend_info = _process_info(self.frontend_proc) + return { + "prepared": self.prepared, + "openai_key_set": self.openai_key is not None, + "backend": backend_info, + "frontend": frontend_info, + "messages": list(self.messages), + "errors": list(self.errors), + } + + +def _process_info(proc: Optional[subprocess.Popen]) -> Dict[str, object]: + if proc is None: + return {"running": False, "pid": None, "returncode": None} + running = proc.poll() is None + return { + "running": running, + "pid": proc.pid, + "returncode": None if running else proc.returncode, + } + + +def run_command( + cmd: List[str], + *, + cwd: Optional[str], + state: LauncherState, + description: Optional[str] = None, +) -> None: + state.add_message(description or f"Executing: {' '.join(cmd)}") + try: + subprocess.run(cmd, cwd=cwd, check=True) + except subprocess.CalledProcessError as exc: + state.add_error(f"Command failed (exit {exc.returncode}): {' '.join(exc.cmd)}") + raise + + +def ensure_environment(state: LauncherState) -> None: + state.add_message("Validating prerequisite tools...") + npm_path = shutil.which("npm") + if npm_path is None: + raise RuntimeError( + "npm was not found in PATH. Install Node.js (includes npm) and ensure it is available." + ) + state.add_message(f"npm detected at {npm_path}") + state.npm_path = npm_path + + python_path = _venv_python() + if not python_path.exists(): + state.add_message("Creating Python virtual environment (.venv)...") + try: + subprocess.run( + [sys.executable, "-m", "venv", str(VENV_DIR)], + cwd=str(REPO_DIR), + check=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"Virtual environment creation failed (exit {exc.returncode})." + ) from exc + python_path = _venv_python() + else: + state.add_message("Virtual environment already exists.") + + if not python_path.exists(): + raise RuntimeError("Virtual environment is missing expected python executable.") + + python_executable = str(python_path) + state.python_path = python_executable + + try: + run_command( + [python_executable, "-m", "pip", "install", "--upgrade", "pip"], + cwd=str(REPO_DIR), + state=state, + description="Upgrading pip inside the virtual environment...", + ) + run_command( + [python_executable, "-m", "pip", "install", "-r", str(BACKEND_REQUIREMENTS)], + cwd=str(REPO_DIR), + state=state, + description="Installing backend Python dependencies...", + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError("Failed to install backend dependencies.") from exc + + frontend_modules = FRONTEND_DIR / "node_modules" + if not frontend_modules.exists(): + try: + run_command( + ["npm", "install"], + cwd=str(FRONTEND_DIR), + state=state, + description="Installing frontend dependencies via npm install...", + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError("Failed to install frontend dependencies via npm.") from exc + else: + state.add_message("Frontend dependencies already installed.") + + state.set_prepared(True) + state.add_message("Environment preparation complete.") + + +def launch_backend(state: LauncherState, key: str) -> subprocess.Popen: + state.add_message("Starting backend server (uvicorn)...") + env = os.environ.copy() + env["RIGHTCODES_OPENAI_API_KEY"] = key + env["RIGHTCODES_OPENAI_API_KEY_SOURCE"] = "launcher" + + python_executable = state.python_path or str(_venv_python()) + python_path = pathlib.Path(python_executable) + if not python_path.exists(): + raise RuntimeError( + "Python executable inside the virtual environment was not found. " + "Re-run the launcher setup before starting services." + ) + state.python_path = python_executable + try: + kwargs: Dict[str, object] = {"env": env, "cwd": str(REPO_DIR)} + if _is_windows(): + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + ) + else: + kwargs["start_new_session"] = True + + proc = subprocess.Popen( + [ + python_executable, + "-m", + "uvicorn", + "server.app.main:app", + "--reload", + "--port", + "8000", + ], + **kwargs, + ) + except OSError as exc: + raise RuntimeError(f"Failed to launch backend server: {exc}") from exc + + state.add_message(f"Backend console launched (PID {proc.pid}).") + return proc + + +def launch_frontend(state: LauncherState, key: str) -> subprocess.Popen: + state.add_message("Starting frontend dev server (npm run dev)...") + env = os.environ.copy() + env["RIGHTCODES_OPENAI_API_KEY"] = key + env["RIGHTCODES_OPENAI_API_KEY_SOURCE"] = "launcher" + + npm_path = state.npm_path or shutil.which("npm") + if npm_path is None: + raise RuntimeError("npm was not found; cannot launch frontend.") + + try: + kwargs: Dict[str, object] = {"env": env, "cwd": str(FRONTEND_DIR)} + if _is_windows(): + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + ) + else: + kwargs["start_new_session"] = True + + proc = subprocess.Popen([npm_path, "run", "dev"], **kwargs) + except OSError as exc: + raise RuntimeError(f"Failed to launch frontend dev server: {exc}") from exc + + state.add_message(f"Frontend console launched (PID {proc.pid}).") + return proc + + +def stop_process(proc: Optional[subprocess.Popen], name: str, state: LauncherState) -> bool: + if not proc: + return False + if proc.poll() is not None: + state.add_message(f"{name.capitalize()} already stopped (exit {proc.returncode}).") + return False + pid = proc.pid + state.add_message(f"Stopping {name} (PID {pid})...") + + if _is_windows(): + try: + result = subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode not in (0, 128, 255): + state.add_error( + f"taskkill exited with code {result.returncode} while stopping {name} (PID {pid})." + ) + except FileNotFoundError: + state.add_error("taskkill command not found; falling back to direct termination.") + except Exception as exc: # pragma: no cover - defensive + state.add_error(f"taskkill failed for {name} (PID {pid}): {exc}") + + else: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except Exception as exc: + state.add_error(f"Failed to send SIGTERM to {name} (PID {pid}): {exc}") + + if proc.poll() is None: + try: + proc.terminate() + except Exception as exc: # pragma: no cover - defensive + state.add_error(f"Failed to send terminate() to {name} (PID {pid}): {exc}") + + try: + proc.wait(10) + except subprocess.TimeoutExpired: + if _is_windows(): + state.add_message(f"{name.capitalize()} unresponsive; forcing termination.") + try: + proc.kill() + except Exception as exc: # pragma: no cover - defensive + state.add_error(f"Failed to terminate {name} (PID {pid}) via kill(): {exc}") + try: + proc.wait(5) + except subprocess.TimeoutExpired: + state.add_error(f"{name.capitalize()} did not exit after kill(); giving up.") + else: + state.add_message(f"{name.capitalize()} unresponsive; sending SIGKILL.") + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + except Exception as exc: # pragma: no cover - defensive + state.add_error(f"Failed to send SIGKILL to {name} (PID {pid}): {exc}") + try: + proc.wait(5) + except subprocess.TimeoutExpired: + state.add_error(f"{name.capitalize()} did not exit after SIGKILL; giving up.") + + state.add_message(f"{name.capitalize()} stopped.") + return True + + +def start_services(state: LauncherState, key: str) -> List[str]: + cleaned_key = key.strip() + if cleaned_key.startswith('"') and cleaned_key.endswith('"') and len(cleaned_key) > 1: + cleaned_key = cleaned_key[1:-1] + if not cleaned_key: + raise ValueError("OpenAI API key is required.") + + state.set_openai_key(cleaned_key) + messages: List[str] = [] + + with state.lock: + backend_proc = state.backend_proc + frontend_proc = state.frontend_proc + backend_running = backend_proc and backend_proc.poll() is None + frontend_running = frontend_proc and frontend_proc.poll() is None + + if backend_running: + msg = f"Backend already running (PID {backend_proc.pid})." + state.add_message(msg) + messages.append(msg) + else: + backend_proc = launch_backend(state, cleaned_key) + state.record_backend(backend_proc) + messages.append(f"Backend launching (PID {backend_proc.pid}).") + + if frontend_running: + msg = f"Frontend already running (PID {frontend_proc.pid})." + state.add_message(msg) + messages.append(msg) + else: + frontend_proc = launch_frontend(state, cleaned_key) + state.record_frontend(frontend_proc) + messages.append(f"Frontend launching (PID {frontend_proc.pid}).") + + open_dashboard(state) + return messages + + +def stop_services(state: LauncherState) -> List[str]: + processes = state.detach_processes() + messages: List[str] = [] + if stop_process(processes["backend"], "backend", state): + messages.append("Backend stopped.") + if stop_process(processes["frontend"], "frontend", state): + messages.append("Frontend stopped.") + if not messages: + messages.append("No services were running.") + return messages + + +def open_dashboard(state: LauncherState) -> None: + if not state.mark_dashboard_opened(): + return + + def _open() -> None: + time.sleep(2) + webbrowser.open("http://localhost:5173") + + state.add_message("Opening RightCodes dashboard in browser (http://localhost:5173)...") + threading.Thread(target=_open, daemon=True).start() + + +def open_launcher_ui(host: str, port: int, state: LauncherState) -> None: + if not state.mark_ui_opened(): + return + + url = f"http://{host}:{port}/" + state.add_message(f"Opening launcher UI in browser ({url})...") + + def _open() -> None: + time.sleep(1) + webbrowser.open(url) + + threading.Thread(target=_open, daemon=True).start() + + +def cleanup_processes(state: LauncherState) -> None: + processes = state.detach_processes() + for name in ("backend", "frontend"): + proc = processes.get(name) + if proc and proc.poll() is None: + try: + stop_process(proc, name, state) + except Exception as exc: # pragma: no cover - best-effort cleanup + state.add_error(f"Failed to stop {name} during cleanup: {exc}") + + +def make_handler(state: LauncherState, html_content: str): + class LauncherHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format: str, *args) -> None: # noqa: D401 - silence default logging + return + + def _send_html(self, content: str, status: HTTPStatus = HTTPStatus.OK) -> None: + encoded = content.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_json(self, payload: Dict[str, object], status: HTTPStatus = HTTPStatus.OK) -> None: + encoded = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def do_GET(self) -> None: # noqa: D401 + path = urllib.parse.urlsplit(self.path).path + if path in ("/", "/index.html"): + self._send_html(html_content) + elif path == "/status": + self._send_json(state.get_status()) + else: + self.send_error(HTTPStatus.NOT_FOUND, "Not Found") + + def do_POST(self) -> None: # noqa: D401 + path = urllib.parse.urlsplit(self.path).path + if path == "/start": + self._handle_start() + elif path == "/stop": + self._handle_stop() + elif path == "/shutdown": + self._handle_shutdown() + else: + self.send_error(HTTPStatus.NOT_FOUND, "Not Found") + + def _read_body(self) -> str: + length = int(self.headers.get("Content-Length", "0") or "0") + return self.rfile.read(length).decode("utf-8") + + def _handle_start(self) -> None: + if not state.prepared: + self._send_json( + { + "ok": False, + "error": "Environment setup did not complete successfully. Review the log above and restart the launcher once issues are resolved.", + "status": state.get_status(), + }, + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) + return + + body = self._read_body() + content_type = self.headers.get("Content-Type", "") + key: str + if "application/json" in content_type: + data = json.loads(body or "{}") + key = str(data.get("openai_key", "") or "") + else: + data = urllib.parse.parse_qs(body) + key = data.get("openai_key", [""])[0] + + try: + messages = start_services(state, key) + except ValueError as exc: + self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + return + except RuntimeError as exc: + state.add_error(str(exc)) + self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.INTERNAL_SERVER_ERROR) + return + except Exception as exc: # pragma: no cover - unexpected failures + state.add_error(f"Unexpected error while starting services: {exc}") + self._send_json( + {"ok": False, "error": "Unexpected error starting services. Check the console for details."}, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + return + + self._send_json({"ok": True, "message": messages, "status": state.get_status()}) + + def _handle_stop(self) -> None: + messages = stop_services(state) + self._send_json({"ok": True, "message": messages, "status": state.get_status()}) + + def _handle_shutdown(self) -> None: + messages = stop_services(state) + shutdown_note = "Launcher shutting down. You can close this tab." + state.add_message(shutdown_note) + payload = { + "ok": True, + "message": [shutdown_note, *messages] if messages else [shutdown_note], + "status": state.get_status(), + } + self._send_json(payload) + + def _shutdown() -> None: + time.sleep(0.5) + try: + cleanup_processes(state) + finally: + try: + self.server.shutdown() + except Exception as exc: # pragma: no cover - best effort + state.add_error(f"Failed to shut down launcher server: {exc}") + + threading.Thread(target=_shutdown, daemon=True).start() + + return LauncherHandler + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="RightCodes launcher with HTML control panel.") + parser.add_argument("--host", default="127.0.0.1", help="Host/interface for the launcher UI (default: 127.0.0.1).") + parser.add_argument("--port", type=int, default=8765, help="Port for the launcher UI (default: 8765).") + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not automatically open the launcher UI in the default browser.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not LAUNCHER_HTML_PATH.exists(): + print(f"[ERROR] Launcher HTML file not found: {LAUNCHER_HTML_PATH}") + return 1 + + html_content = LAUNCHER_HTML_PATH.read_text(encoding="utf-8") + state = LauncherState() + + try: + ensure_environment(state) + except Exception as exc: + state.add_error(str(exc)) + state.add_message( + "Environment preparation failed. Resolve the error(s) above and restart the launcher." + ) + + handler_class = make_handler(state, html_content) + server = http.server.ThreadingHTTPServer((args.host, args.port), handler_class) + state.add_message(f"Launcher UI available at http://{args.host}:{args.port}/") + + atexit.register(cleanup_processes, state) + if not args.no_browser: + open_launcher_ui(args.host, args.port, state) + + try: + server.serve_forever() + except KeyboardInterrupt: + state.add_message("Launcher interrupted; shutting down.") + finally: + server.server_close() + cleanup_processes(state) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/temp_patch.txt b/temp_patch.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/temp_patch.txt @@ -0,0 +1 @@ + diff --git a/tools/build_offline_package.py b/tools/build_offline_package.py new file mode 100644 index 0000000000000000000000000000000000000000..7fe0e417ad13505052e7631584c5a1154e352704 --- /dev/null +++ b/tools/build_offline_package.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Create an offline-ready bundle of the RightCodes workspace. + +The bundle includes: + * The full repository contents (excluding transient caches like dist/ and .git/) + * The populated Python virtual environment (.venv/) + * The frontend node_modules/ directory + +Usage: + python tools/build_offline_package.py [--output dist/rightcodes-offline.zip] + +Run this after the launcher has successfully installed dependencies so the +virtualenv and node_modules folders exist. Distribute the resulting archive +to machines that need an offline copy (same OS/architecture as the machine +that produced the bundle). +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Iterable +from zipfile import ZIP_DEFLATED, ZipFile + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DIST_DIR = REPO_ROOT / "dist" +DEFAULT_OUTPUT = DIST_DIR / "rightcodes-offline.zip" + +MANDATORY_PATHS = [ + REPO_ROOT / ".venv", + REPO_ROOT / "frontend" / "node_modules", +] + +EXCLUDED_NAMES = { + ".git", + ".mypy_cache", + "__pycache__", + ".pytest_cache", + "dist", + ".idea", + ".vscode", +} + +EXCLUDED_SUFFIXES = {".pyc", ".pyo", ".pyd", ".log"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"Path to the archive to create (default: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite the output archive if it already exists.", + ) + parser.add_argument( + "--python-runtime", + type=Path, + help="Path to a portable Python runtime directory to embed (optional).", + ) + parser.add_argument( + "--node-runtime", + type=Path, + help="Path to a portable Node.js runtime directory to embed (optional).", + ) + return parser.parse_args() + + +def ensure_prerequisites(paths: Iterable[Path]) -> None: + missing = [str(p) for p in paths if not p.exists()] + if missing: + joined = "\n - ".join(missing) + raise SystemExit( + "Cannot create offline bundle. Ensure the launcher has run once so the following paths exist:\n" + f" - {joined}" + ) + + +def main() -> int: + args = parse_args() + + ensure_prerequisites(MANDATORY_PATHS) + + if args.python_runtime and not args.python_runtime.exists(): + raise SystemExit(f"Portable Python runtime not found: {args.python_runtime}") + if args.node_runtime and not args.node_runtime.exists(): + raise SystemExit(f"Portable Node.js runtime not found: {args.node_runtime}") + + output_path = args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + + if output_path.exists(): + if args.force: + output_path.unlink() + else: + raise SystemExit(f"Output archive already exists: {output_path} (use --force to overwrite).") + + print(f"[INFO] Creating offline bundle at {output_path}") + with tempfile.TemporaryDirectory() as tmpdir: + staging_root = Path(tmpdir) / REPO_ROOT.name + shutil.copytree(REPO_ROOT, staging_root, dirs_exist_ok=True) + for excluded in EXCLUDED_NAMES: + for candidate in staging_root.rglob(excluded): + if candidate.is_dir(): + shutil.rmtree(candidate, ignore_errors=True) + else: + candidate.unlink(missing_ok=True) + for suffix in EXCLUDED_SUFFIXES: + for candidate in staging_root.rglob(f"*{suffix}"): + candidate.unlink(missing_ok=True) + + portable_root = staging_root / "portable" + if args.python_runtime: + destination = portable_root / "python" + print(f"[INFO] Embedding portable Python runtime from {args.python_runtime} -> {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + shutil.copytree(args.python_runtime, destination) + if args.node_runtime: + destination = portable_root / "node" + print(f"[INFO] Embedding portable Node.js runtime from {args.node_runtime} -> {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + shutil.rmtree(destination, ignore_errors=True) + shutil.copytree(args.node_runtime, destination) + + with ZipFile(output_path, mode="w", compression=ZIP_DEFLATED, compresslevel=9) as zip_file: + for item in sorted(staging_root.rglob("*")): + arcname = item.relative_to(staging_root).as_posix() + if item.is_dir(): + zip_file.writestr(arcname + "/", "") + continue + zip_file.write(item, arcname=arcname) + + print("[INFO] Offline bundle created successfully.") + print("[INFO] Distribute the archive, extract it on the target machine, then launch via start-rightcodes.ps1/.bat.") + return 0 + + +if __name__ == "__main__": + os.umask(0o022) + sys.exit(main()) diff --git a/workers/README.md b/workers/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2a95907c0ff6140909b8f1b64bc85d9238dace76 --- /dev/null +++ b/workers/README.md @@ -0,0 +1,12 @@ +# Worker Pipelines + +Async and background jobs that ingest user artefacts, parse documents, and execute the staged processing pipelines without blocking the API. + +## Structure + +- `ingestion/` - Session bootstrap jobs: file validation, antivirus scanning, metadata capture. +- `docx_processing/` - Word-to-markdown and JSON conversion tasks plus style map extraction (see `parse_docx` for paragraph/table summarisation). +- `pdf_processing/` - OCR, text extraction, and structure inference for standards PDFs (see `parse_pdf` for page chunking with code detection and OCR supplements). +- `pipelines/` - Composed job graphs representing the multi-agent workflow. +- `queue/` - Task queue adapters (Celery, RQ, or custom) and worker entrypoints. +- `tests/` - Worker-level unit tests and pipeline simulation suites. diff --git a/workers/__init__.py b/workers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..426e8155df14d11aa5255535b8d6f56a86a6144c --- /dev/null +++ b/workers/__init__.py @@ -0,0 +1,3 @@ +from .pipelines.bootstrap import bootstrap_pipeline_runner + +__all__ = ["bootstrap_pipeline_runner"] diff --git a/workers/docx_processing/__init__.py b/workers/docx_processing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fbc23d3dd35e1252b787d6e81de82621e3446919 --- /dev/null +++ b/workers/docx_processing/__init__.py @@ -0,0 +1,8 @@ +from .parser import DocxParseResult, TableBlock, ParagraphBlock, parse_docx + +__all__ = [ + "DocxParseResult", + "TableBlock", + "ParagraphBlock", + "parse_docx", +] diff --git a/workers/docx_processing/parser.py b/workers/docx_processing/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..1232bfeb29601f4f770321876b9fab2fd7594620 --- /dev/null +++ b/workers/docx_processing/parser.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, List, Optional, Sequence + +from docx import Document +from docx.table import _Cell as DocxCell # type: ignore[attr-defined] +from docx.text.paragraph import Paragraph as DocxParagraph + +REFERENCE_PATTERN = re.compile(r"(?P[A-Z]{2,}[\s-]?\d{2,}(?:\.\d+)*)") +HEADING_PATTERN = re.compile(r"Heading\s+(\d+)", re.IGNORECASE) + + +@dataclass +class ParagraphBlock: + index: int + text: str + style: Optional[str] = None + heading_level: Optional[int] = None + references: list[str] = field(default_factory=list) + + +@dataclass +class TableBlock: + index: int + rows: list[list[str]] + references: list[str] = field(default_factory=list) + + +@dataclass +class DocxParseResult: + path: Path + paragraphs: list[ParagraphBlock] = field(default_factory=list) + tables: list[TableBlock] = field(default_factory=list) + + +def parse_docx(path: Path) -> DocxParseResult: + if not path.exists(): + raise FileNotFoundError(path) + + document = Document(path) + result = DocxParseResult(path=path) + + for idx, paragraph in enumerate(_iter_paragraphs(document.paragraphs)): + text = paragraph.text.strip() + if not text: + continue + references = _extract_references(text) + style_name = paragraph.style.name if paragraph.style else None + heading_level = _resolve_heading_level(style_name) + result.paragraphs.append( + ParagraphBlock( + index=idx, + text=text, + style=style_name, + heading_level=heading_level, + references=references, + ) + ) + + for idx, docx_table in enumerate(document.tables): + rows: list[list[str]] = [] + references: list[str] = [] + for row in docx_table.rows: + cells_text = [_normalise_text(cell) for cell in row.cells] + rows.append(cells_text) + for value in cells_text: + references.extend(_extract_references(value)) + result.tables.append( + TableBlock( + index=idx, + rows=rows, + references=sorted(set(references)), + ) + ) + + return result + + +def _resolve_heading_level(style_name: Optional[str]) -> Optional[int]: + if not style_name: + return None + match = HEADING_PATTERN.search(style_name) + if match: + try: + return int(match.group(1)) + except ValueError: + return None + return None + + +def _iter_paragraphs(paragraphs: Sequence[DocxParagraph]) -> Iterable[DocxParagraph]: + yield from paragraphs + + +def _normalise_text(cell: DocxCell) -> str: + value = cell.text.strip() + return re.sub(r"\s+", " ", value) + + +def _extract_references(value: str) -> list[str]: + return sorted({match.group("code") for match in REFERENCE_PATTERN.finditer(value)}) diff --git a/workers/pdf_processing/__init__.py b/workers/pdf_processing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e916881832d43bcb9220d6f061e4cf6506e554d6 --- /dev/null +++ b/workers/pdf_processing/__init__.py @@ -0,0 +1,7 @@ +from .parser import PdfParseResult, PdfPageChunk, parse_pdf + +__all__ = [ + "PdfParseResult", + "PdfPageChunk", + "parse_pdf", +] diff --git a/workers/pdf_processing/parser.py b/workers/pdf_processing/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..70922588add7d3a8734fac37975f5c9ce4deca5c --- /dev/null +++ b/workers/pdf_processing/parser.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import difflib +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, List, Optional + +import pdfplumber + +try: + import pytesseract +except ImportError: # pragma: no cover - optional dependency + pytesseract = None + +logger = logging.getLogger(__name__) + +OCR_AVAILABLE = pytesseract is not None + +REFERENCE_PATTERN = re.compile( + r"(?P[A-Z]{2,}[\s-]?\d{2,}(?:\.\d+)*)" +) +CLAUSE_PATTERN = re.compile(r"\b\d+(?:\.\d+){1,3}\b") + + +@dataclass +class PdfPageChunk: + page_number: int + chunk_index: int + text: str + heading: Optional[str] = None + clause_numbers: list[str] = field(default_factory=list) + references: list[str] = field(default_factory=list) + is_ocr: bool = False + + +@dataclass +class PdfParseResult: + path: Path + chunks: list[PdfPageChunk] = field(default_factory=list) + ocr_pages: list[int] = field(default_factory=list) + + +def parse_pdf(path: Path, *, max_chunk_chars: int = 1200) -> PdfParseResult: + if not path.exists(): + raise FileNotFoundError(path) + + result = PdfParseResult(path=path) + + with pdfplumber.open(path) as pdf: + for page_number, page in enumerate(pdf.pages, start=1): + base_text = page.extract_text() or "" + ocr_text = "" + ocr_additions: list[str] = [] + + if OCR_AVAILABLE: + try: + image = page.to_image(resolution=300).original + ocr_text = pytesseract.image_to_string(image) or "" + ocr_additions = _extract_ocr_additions(base_text, ocr_text) + except Exception as exc: # noqa: BLE001 + logger.warning("OCR failed for %s page %s: %s", path, page_number, exc) + ocr_text = ocr_text or "" + ocr_additions = [] + + chunk_counter = 0 + base_text_stripped = base_text.strip() + if base_text_stripped: + for chunk in _chunk_text(base_text_stripped, max_chunk_chars): + references = _extract_references(chunk) + heading = _detect_heading(chunk) + clause_numbers = sorted({match.group(0) for match in CLAUSE_PATTERN.finditer(chunk)}) + result.chunks.append( + PdfPageChunk( + page_number=page_number, + chunk_index=chunk_counter, + text=chunk, + heading=heading, + clause_numbers=clause_numbers, + references=references, + is_ocr=False, + ) + ) + chunk_counter += 1 + + additions_text = "\n".join(ocr_additions).strip() + if additions_text: + if page_number not in result.ocr_pages: + result.ocr_pages.append(page_number) + for chunk in _chunk_text(additions_text, max_chunk_chars): + references = _extract_references(chunk) + heading = _detect_heading(chunk) + clause_numbers = sorted({match.group(0) for match in CLAUSE_PATTERN.finditer(chunk)}) + result.chunks.append( + PdfPageChunk( + page_number=page_number, + chunk_index=chunk_counter, + text=chunk, + heading=heading, + clause_numbers=clause_numbers, + references=references, + is_ocr=True, + ) + ) + chunk_counter += 1 + + if not base_text_stripped and not additions_text: + logger.debug("Skipping empty page %s in %s", page_number, path) + + return result + + +def _chunk_text(text: str, max_chunk_chars: int) -> Iterable[str]: + cleaned = re.sub(r"\s+", " ", text).strip() + if len(cleaned) <= max_chunk_chars: + yield cleaned + return + + sentences = re.split(r"(?<=[\.\?!])\s+", cleaned) + buffer: List[str] = [] + buffer_len = 0 + for sentence in sentences: + sentence_len = len(sentence) + if buffer_len + sentence_len + 1 > max_chunk_chars and buffer: + yield " ".join(buffer).strip() + buffer = [sentence] + buffer_len = sentence_len + else: + buffer.append(sentence) + buffer_len += sentence_len + 1 + if buffer: + yield " ".join(buffer).strip() + + +def _extract_references(value: str) -> list[str]: + return sorted({match.group("code") for match in REFERENCE_PATTERN.finditer(value)}) + + +def _detect_heading(text: str) -> Optional[str]: + lines = text.splitlines() + for line in lines: + stripped = line.strip() + if not stripped: + continue + if stripped.isupper() or stripped.endswith(":") or _looks_like_clause(stripped): + return stripped[:200] + if len(stripped.split()) <= 8 and stripped == stripped.title(): + return stripped[:200] + break + return None + + +def _looks_like_clause(line: str) -> bool: + return bool(re.match(r"^\d+(\.\d+)*", line)) + + +def _extract_ocr_additions(base_text: str, ocr_text: str) -> list[str]: + """Return OCR lines not already present in the base extraction.""" + if not ocr_text.strip(): + return [] + ocr_lines = _normalise_lines(ocr_text) + if not base_text.strip(): + return ocr_lines + base_lines = _normalise_lines(base_text) + matcher = difflib.SequenceMatcher(a=base_lines, b=ocr_lines) + additions: list[str] = [] + for tag, _i1, _i2, j1, j2 in matcher.get_opcodes(): + if tag in ("replace", "insert"): + additions.extend(ocr_lines[j1:j2]) + return additions + + +def _normalise_lines(text: str) -> list[str]: + return [line.strip() for line in text.splitlines() if line.strip()] diff --git a/workers/pipelines/bootstrap.py b/workers/pipelines/bootstrap.py new file mode 100644 index 0000000000000000000000000000000000000000..d8973f06f8aaedd3d38e6aeb34eb939158eae063 --- /dev/null +++ b/workers/pipelines/bootstrap.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable + +from agents import ( + ExportAgent, + ExtractionAgent, + OrchestratorAgent, + RewriteAgent, + StandardsMappingAgent, + ValidationAgent, +) +from agents.shared.base import AgentContext + +AgentFactory = Callable[[], Awaitable[dict]] + + +async def bootstrap_pipeline_runner(session_id: str) -> None: + """Demonstrate how the agent chain could be invoked asynchronously.""" + context = AgentContext(session_id=session_id, payload={}) + orchestrator = OrchestratorAgent() + extraction = ExtractionAgent() + standards = StandardsMappingAgent() + rewrite = RewriteAgent() + validation = ValidationAgent() + export = ExportAgent() + + await orchestrator.emit_debug("Bootstrapping placeholder pipeline.") + + stage_outputs = [] + stage_outputs.append(await orchestrator.run(context)) + stage_outputs.append(await extraction.run(context)) + stage_outputs.append(await standards.run(context)) + stage_outputs.append(await rewrite.run(context)) + stage_outputs.append(await validation.run(context)) + stage_outputs.append(await export.run(context)) + + await orchestrator.emit_debug(f"Pipeline completed with {len(stage_outputs)} stage outputs.") diff --git a/workers/queue/runner.py b/workers/queue/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..de33fcba1f45c3d1e8dc42226d67fb30dbf439af --- /dev/null +++ b/workers/queue/runner.py @@ -0,0 +1,20 @@ +import asyncio +from typing import Callable + +from .types import TaskHandler + + +class InMemoryQueue: + """Simple FIFO queue that simulates background processing.""" + + def __init__(self) -> None: + self._handlers: dict[str, TaskHandler] = {} + + def register(self, name: str, handler: TaskHandler) -> None: + self._handlers[name] = handler + + async def enqueue(self, name: str, **payload) -> None: + handler = self._handlers.get(name) + if not handler: + raise ValueError(f"Handler not registered for task `{name}`") + await handler(**payload) diff --git a/workers/queue/types.py b/workers/queue/types.py new file mode 100644 index 0000000000000000000000000000000000000000..e8c9e4137ab95581ed61654ae6438a795b6ac293 --- /dev/null +++ b/workers/queue/types.py @@ -0,0 +1,3 @@ +from typing import Awaitable, Callable + +TaskHandler = Callable[..., Awaitable[None]]