Improvements

This commit is contained in:
Reece
2026-04-08 14:58:35 +01:00
parent a96b95e198
commit 2306200c1e
42 changed files with 4916 additions and 19 deletions
-9
View File
@@ -1,9 +0,0 @@
# Configure the model strings passed to pydantic-ai. Provider credentials are handled by
# pydantic-ai and should be set using the provider's native environment variables, for example
# ANTHROPIC_API_KEY or OPENAI_API_KEY.
STIRLING_SMART_MODEL=anthropic:claude-haiku-4-5
STIRLING_FAST_MODEL=anthropic:claude-haiku-4-5
# Default output token limits applied by the engine for each model tier.
STIRLING_SMART_MODEL_MAX_TOKENS=8192
STIRLING_FAST_MODEL_MAX_TOKENS=2048
+6
View File
@@ -1,6 +1,9 @@
"""Agent modules for Stirling AI reasoning flows."""
from .execution import ExecutionPlanningAgent
from .form_analyser import FormAnalyserAgent
from .form_fill import FormFillAgent
from .form_filler import FormFillerAgent
from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
@@ -8,6 +11,9 @@ from .user_spec import UserSpecAgent
__all__ = [
"ExecutionPlanningAgent",
"FormAnalyserAgent",
"FormFillAgent",
"FormFillerAgent",
"OrchestratorAgent",
"PdfEditAgent",
"PdfEditParameterSelector",
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts.form_fill import (
FormAnalysisRequest,
FormAnalysisResponse,
FormField,
)
from stirling.services import AppRuntime
FORM_ANALYSER_SYSTEM_PROMPT = """\
You are a PDF form analyser. You receive field metadata from one or more PDF forms.
Your job (analysis ONLY — do NOT fill any values):
1. PER-FILE ROLE DETECTION: For each file, identify distinct sections and the role each \
represents. Use field name prefixes (e.g. "Client" vs "Beneficiary"), nearby page text \
section headers, and form context to group fields by role. Mark which role is the \
"primary person" (the form submitter). If a form has only one section or no section \
distinction, create a single role for it and mark it as primary.
2. CROSS-FILE ROLE MERGING: When the same conceptual role appears in multiple files \
(e.g. "Client" in file A and "Applicant" in file B both mean the primary form submitter), \
merge them into a single cross_file_role with a canonical label. Use semantic understanding \
— "Client", "Applicant", "Account Holder" may all be the same role. Only merge when \
confident they refer to the same type of person/entity.
3. LABEL CLEANUP: For fields with unreadable labels (numeric codes like "0021-6009", \
technical IDs like "fld_x7q", names with brackets or underscores), find the real label \
from the nearby page text. Use page text verbatim. Do not clean labels that are already \
clear readable text.
4. INTERNAL FIELD DETECTION: Identify system fields (form IDs, submission references, \
tracking codes, barcodes, buttons, submit buttons). Return their field names in \
skipped_field_names per file.
STRICT RULES:
- Do NOT return any fill values. This is analysis only.
- Every file must have at least one detected role.
- cross_file_roles must cover ALL detected roles across all files.
- If a role only appears in one file, it still appears in cross_file_roles with one file_id.
- field_names_by_file must map each file_id to the field names belonging to that role in that file.
"""
MAX_PAGE_TEXT_CHARS = 1500
class FormAnalyserAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.agent = Agent(
model=runtime.smart_model,
output_type=NativeOutput(FormAnalysisResponse),
system_prompt=FORM_ANALYSER_SYSTEM_PROMPT,
model_settings=runtime.smart_model_settings,
)
async def analyse(self, request: FormAnalysisRequest) -> FormAnalysisResponse:
prompt = self._build_prompt(request)
result = await self.agent.run(prompt)
return result.output
def _build_prompt(self, request: FormAnalysisRequest) -> str:
sections = []
for file_set in request.files:
page_texts: dict[str, int] = {}
field_page_map: dict[str, int] = {}
for field in file_set.form_fields:
if field.nearby_page_text:
text = field.nearby_page_text
if text not in page_texts:
page_texts[text] = len(page_texts)
field_page_map[field.name] = page_texts[text]
page_section = "\n".join(
f" [Page {pid}]: {text[:MAX_PAGE_TEXT_CHARS]}" for text, pid in page_texts.items()
)
fields_text = "\n".join(self._format_field(f, field_page_map.get(f.name)) for f in file_set.form_fields)
sections.append(
f"=== FILE: {file_set.file_name} (id={file_set.file_id}) ===\n"
f"Page texts:\n{page_section}\n\n"
f"Fields:\n{fields_text}"
)
return (
f"Analyse {len(request.files)} form(s):\n\n"
+ "\n\n".join(sections)
+ "\n\nDetect roles per file, merge matching roles across files, "
"clean unreadable labels, identify internal/system fields."
)
def _format_field(self, field: FormField, page_id: int | None) -> str:
parts = [f"- name={field.name}, type={field.type}"]
if field.label:
parts.append(f" label={field.label}")
if field.tooltip:
parts.append(f" tooltip={field.tooltip}")
if field.options:
parts.append(f" options={field.options}")
if field.required:
parts.append(" required=true")
if field.read_only:
parts.append(" readOnly=true")
if page_id is not None:
parts.append(f" page={page_id}")
return "\n".join(parts)
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import ConversationMessage
from stirling.contracts.form_fill import (
DocumentExtractionRequest,
DocumentExtractionResponse,
FormField,
FormFillClarificationResponse,
FormFillRequest,
FormFillResponse,
FormFillResultResponse,
KnowledgeUpdateResponse,
MultiProfileExtractionResponse,
RoleConfirmationResponse,
RoleDetectionResult,
)
from stirling.services import AppRuntime
FIELD_MATCHER_SYSTEM_PROMPT = """\
You are a PDF form field matcher. You receive a list of PDF form fields and a knowledge \
dictionary of confirmed user information.
Your job:
1. ROLE DETECTION: Analyze the form to identify distinct sections and the role each represents. \
Use field name prefixes (e.g. "Client" vs "Beneficiary"), nearby page text section headers, \
and form context to group fields by role. Return this in role_detection.
2. CONFIDENCE SCORING for primary_confidence (0.0 to 1.0):
- 0.9-1.0: Clear primary-person keywords in field names or page text (Client, Applicant, \
Employee, Patient, Account Holder, Policyholder, Tenant, Borrower) AND only one plausible \
primary section.
- 0.7-0.9: Section headers suggest a primary person but terminology is ambiguous (e.g. \
"Party A" vs "Party B"), or the form title suggests the user might be an unusual role \
(e.g. claim form where user = claimant, not policyholder).
- 0.5-0.7: Multiple sections could plausibly be the primary person, or generic labels \
like "Name" without section context.
- Below 0.5: No section structure detected, or all sections appear to be for third parties.
In confidence_reasoning, state which specific signals you used.
If the form has only one section or no section distinction, set primary_confidence to 1.0.
3. FIELD MATCHING: Match form fields to knowledge entries where the field is clearly asking \
for the same information, just worded differently. ONLY fill fields in the detected primary \
person section. Return these as filled_fields.
4. INTERNAL FIELDS: Identify system fields (form IDs, submission references, tracking codes, \
barcodes, buttons, submit buttons). Return their field names in skipped_field_names.
5. LABEL CLEANUP: For fields with unreadable labels (numeric codes, technical IDs, brackets, \
underscores), find the real label from nearby page text and return it in cleaned_labels. \
Use page text verbatim. Do not clean labels that are already readable.
{role_override_instruction}
STRICT RULES:
- ONLY fill fields belonging to the primary person section.
- ONLY fill a field if a knowledge entry is clearly the same information.
- For checkbox fields: value must be "Yes" or "Off".
- For radio/combobox/listbox fields: value MUST be one of the field's options.
- Skip read-only fields entirely.
- role_detection is REQUIRED in every response.
"""
ROLE_OVERRIDE_INSTRUCTION = """\
ROLE OVERRIDE: The user has confirmed they are the "{role_label}" in this form. \
Treat all fields in that section as the primary person section unconditionally. \
Set primary_confidence to 1.0."""
DOCUMENT_EXTRACTOR_SYSTEM_PROMPT = """\
You extract structured personal information from document text. The user has uploaded a document \
(CV, ID, utility bill, etc.) and wants to store the extracted information for future form filling.
STRICT RULES:
- Only extract information that is explicitly stated in the document. Never infer or guess.
- Use consistent snake_case keys. Preferred keys include: first_name, last_name, full_name, \
date_of_birth, email, phone, address_line_1, address_line_2, city, state, zip_code, country, \
job_title, company, nationality, gender, marital_status, passport_number, driver_license_number, \
social_security_number, tax_id.
- Use additional descriptive keys when the information doesn't fit the preferred keys.
- Set the source field to describe the document type (e.g., "extracted from CV", \
"extracted from utility bill").
- Provide a brief summary message listing what was extracted.
"""
MULTI_DOC_EXTRACTOR_SYSTEM_PROMPT = """\
You receive text from one or more documents. Your job is to extract personal information and \
determine how many distinct people are represented.
RULES:
- Analyze all documents to identify distinct individuals. Look for different names, different \
contact details, different employment histories, etc.
- If all documents belong to one person, return a single profile.
- If documents belong to different people, group entries by person and return separate profiles.
- For each profile, suggest a name using the person's actual name if found (e.g. "John Smith"). \
If no name is found, use a descriptive name like "Person from document2.pdf".
- Avoid suggesting profile names that conflict with the existing_profile_names list.
- Use consistent snake_case keys: first_name, last_name, full_name, date_of_birth, email, \
phone, address_line_1, address_line_2, city, state, zip_code, country, job_title, company, \
nationality, gender, etc.
- Only extract information explicitly stated in the documents. Never infer or guess.
- Set the source field to indicate which document the entry came from.
"""
CONFIDENCE_THRESHOLD_AUTO = 0.9
CONFIDENCE_THRESHOLD_CONFIRM = 0.5
MAX_PAGE_TEXT_CHARS = 1500
class FormFillAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.field_matcher = Agent(
model=runtime.smart_model,
output_type=NativeOutput(FormFillResultResponse),
system_prompt=FIELD_MATCHER_SYSTEM_PROMPT.format(role_override_instruction=""),
model_settings=runtime.smart_model_settings,
)
self.document_extractor = Agent(
model=runtime.smart_model,
output_type=NativeOutput(KnowledgeUpdateResponse),
system_prompt=DOCUMENT_EXTRACTOR_SYSTEM_PROMPT,
model_settings=runtime.smart_model_settings,
)
self.multi_doc_extractor = Agent(
model=runtime.smart_model,
output_type=NativeOutput(MultiProfileExtractionResponse),
system_prompt=MULTI_DOC_EXTRACTOR_SYSTEM_PROMPT,
model_settings=runtime.smart_model_settings,
)
async def handle(self, request: FormFillRequest) -> FormFillResponse:
if request.extracted_document_text and not request.form_fields:
return await self._extract_knowledge(request)
if not request.form_fields:
return FormFillClarificationResponse(
question="Please upload a PDF form or a document to extract information from.",
reason="No form fields or document text provided.",
)
return await self._fill_form(request)
async def _fill_form(self, request: FormFillRequest) -> FormFillResponse:
response = await self._run_fill_agent(request)
# If role override or preference match, return as-is
if request.role_override or self._preference_matches(request, response):
return response
# Evaluate confidence
if response.role_detection and response.role_detection.primary_confidence < CONFIDENCE_THRESHOLD_AUTO:
return RoleConfirmationResponse(
role_detection=response.role_detection,
suggested_primary=response.role_detection.primary_role_label or "Unknown",
question=self._build_confirmation_question(response.role_detection),
provisional_fills=response.filled_fields,
cleaned_labels=response.cleaned_labels,
skipped_field_names=response.skipped_field_names,
)
return response
async def _run_fill_agent(self, request: FormFillRequest) -> FormFillResultResponse:
role_instruction = ""
if request.role_override:
role_instruction = ROLE_OVERRIDE_INSTRUCTION.format(role_label=request.role_override)
prompt = self._build_fill_prompt(request)
if role_instruction:
agent = Agent(
model=self.runtime.smart_model,
output_type=NativeOutput(FormFillResultResponse),
system_prompt=FIELD_MATCHER_SYSTEM_PROMPT.format(role_override_instruction=role_instruction),
model_settings=self.runtime.smart_model_settings,
)
else:
agent = self.field_matcher
result = await agent.run(prompt)
return result.output
async def _extract_knowledge(self, request: FormFillRequest) -> KnowledgeUpdateResponse:
prompt = self._build_extraction_prompt(request)
result = await self.document_extractor.run(prompt)
return result.output
async def extract_documents(self, request: DocumentExtractionRequest) -> DocumentExtractionResponse:
docs_text = "\n\n".join(f"--- Document: {doc.file_name} ---\n{doc.text[:10000]}" for doc in request.documents)
existing = ", ".join(request.existing_profile_names) if request.existing_profile_names else "None"
prompt = (
f"Documents ({len(request.documents)} total):\n{docs_text}\n\n"
f"Existing profile names (avoid conflicts): {existing}\n\n"
"Identify distinct people, extract their information, and group by person."
)
result = await self.multi_doc_extractor.run(prompt)
return result.output
def _preference_matches(self, request: FormFillRequest, response: FormFillResultResponse) -> bool:
pref = request.knowledge.get("_role_preference", "")
if not pref or not response.role_detection:
return False
keywords = [k.strip().lower() for k in pref.split(",") if k.strip()]
primary_label = (response.role_detection.primary_role_label or "").lower()
return any(kw in primary_label for kw in keywords)
def _build_confirmation_question(self, detection: RoleDetectionResult) -> str:
if detection.primary_confidence >= CONFIDENCE_THRESHOLD_CONFIRM:
return f"Are you the '{detection.primary_role_label}' in this form?"
role_labels = [r.role_label for r in detection.detected_roles]
return f"Which section describes you? Detected sections: {', '.join(role_labels)}"
def _build_fill_prompt(self, request: FormFillRequest) -> str:
# Deduplicate page texts by content, truncate each
page_texts: dict[str, int] = {}
field_page_map: dict[str, int] = {}
for field in request.form_fields:
if field.nearby_page_text:
text = field.nearby_page_text
if text not in page_texts:
page_texts[text] = len(page_texts)
field_page_map[field.name] = page_texts[text]
page_section = "\n".join(f"[Page {pid}]: {text[:MAX_PAGE_TEXT_CHARS]}" for text, pid in page_texts.items())
fields_text = "\n".join(
self._format_form_field(field, field_page_map.get(field.name)) for field in request.form_fields
)
knowledge_text = (
"\n".join(f"- {key}: {value}" for key, value in request.knowledge.items() if not key.startswith("_"))
if request.knowledge
else "No knowledge entries available."
)
history_text = self._format_conversation_history(request.conversation_history)
return (
f"User message: {request.user_message}\n\n"
f"Conversation history:\n{history_text}\n\n"
f"Known user information:\n{knowledge_text}\n\n"
f"Page texts:\n{page_section}\n\n"
f"Form fields:\n{fields_text}\n\n"
"Detect roles, match fields to knowledge for the primary person only. "
"Return cleaned labels for unreadable field names. "
"Return skipped_field_names for internal/system fields."
)
def _build_extraction_prompt(self, request: FormFillRequest) -> str:
history_text = self._format_conversation_history(request.conversation_history)
return (
f"User message: {request.user_message}\n\n"
f"Conversation history:\n{history_text}\n\n"
f"Document text:\n{request.extracted_document_text}\n\n"
"Extract all personal information as structured key-value pairs."
)
def _format_form_field(self, field: FormField, page_id: int | None) -> str:
parts = [f"- name={field.name}, type={field.type}"]
if field.label:
parts.append(f" label={field.label}")
if field.tooltip:
parts.append(f" tooltip={field.tooltip}")
if field.options:
parts.append(f" options={field.options}")
if field.required:
parts.append(" required=true")
if field.read_only:
parts.append(" readOnly=true")
if page_id is not None:
parts.append(f" page={page_id}")
return "\n".join(parts)
def _format_conversation_history(self, conversation_history: list[ConversationMessage]) -> str:
if not conversation_history:
return "None"
return "\n".join(f"- {message.role}: {message.content}" for message in conversation_history)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts.form_fill import (
FormField,
FormFillBatchRequest,
FormFillBatchResponse,
)
from stirling.services import AppRuntime
FORM_FILLER_SYSTEM_PROMPT = """\
You are a PDF form filler. You receive form fields and a knowledge dictionary of \
confirmed user information. Role detection has already been done — all fields given \
to you belong to the user's section.
Your job: Match form fields to knowledge entries and return fill values.
STRICT RULES:
- ONLY fill a field if a knowledge entry is clearly the same information.
- For checkbox fields: value must be "Yes" or "Off".
- For radio/combobox/listbox fields: value MUST be one of the field's options.
- Skip read-only fields.
- Do NOT perform role detection — it is already done.
- Return filled_fields per file using the file_id from the request.
- If no fields match for a file, return an empty filled_fields list for that file.
"""
class FormFillerAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.agent = Agent(
model=runtime.fast_model,
output_type=NativeOutput(FormFillBatchResponse),
system_prompt=FORM_FILLER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def fill_batch(self, request: FormFillBatchRequest) -> FormFillBatchResponse:
prompt = self._build_prompt(request)
result = await self.agent.run(prompt)
return result.output
def _build_prompt(self, request: FormFillBatchRequest) -> str:
knowledge_text = (
"\n".join(f"- {k}: {v}" for k, v in request.knowledge.items() if not k.startswith("_"))
or "No knowledge entries."
)
file_sections = []
for file_req in request.files:
fields_text = "\n".join(self._format_field(f) for f in file_req.form_fields)
file_sections.append(f"=== FILE {file_req.file_id} (role: {file_req.role_label}) ===\n{fields_text}")
return (
f"Known user information:\n{knowledge_text}\n\n"
+ "\n\n".join(file_sections)
+ "\n\nMatch fields to knowledge entries. Return filled_fields per file."
)
def _format_field(self, field: FormField) -> str:
parts = [f"- name={field.name}, type={field.type}"]
if field.label:
parts.append(f" label={field.label}")
if field.tooltip:
parts.append(f" tooltip={field.tooltip}")
if field.options:
parts.append(f" options={field.options}")
if field.required:
parts.append(" required=true")
return "\n".join(parts)
@@ -6,6 +6,7 @@ from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
from pydantic_ai.tools import RunContext
from stirling.agents.form_fill import FormFillAgent
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.user_spec import UserSpecAgent
@@ -20,6 +21,7 @@ from stirling.contracts import (
PdfQuestionResponse,
UnsupportedCapabilityResponse,
)
from stirling.contracts.form_fill import FormFillRequest, FormFillResponse
from stirling.services import AppRuntime
@@ -45,6 +47,11 @@ class OrchestratorAgent:
name="delegate_pdf_question",
description="Delegate questions about PDF contents and return the PDF question result.",
),
ToolOutput(
self.delegate_form_fill,
name="delegate_form_fill",
description="Delegate requests to fill PDF forms or extract personal information from documents for form filling.",
),
ToolOutput(
self.delegate_user_spec,
name="delegate_user_spec",
@@ -62,6 +69,7 @@ class OrchestratorAgent:
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for requested PDF modifications. "
"Use delegate_pdf_question for questions about the contents of a PDF. "
"Use delegate_form_fill for requests to fill PDF forms or extract personal information for form filling. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use unsupported_capability only when none of the other outputs fit."
),
@@ -75,6 +83,10 @@ class OrchestratorAgent:
)
return result.output
async def delegate_form_fill(self, ctx: RunContext[OrchestratorDeps]) -> FormFillResponse:
request = ctx.deps.request
return await FormFillAgent(ctx.deps.runtime).handle(FormFillRequest(user_message=request.user_message))
async def delegate_pdf_edit(self, ctx: RunContext[OrchestratorDeps]) -> PdfEditResponse:
request = ctx.deps.request
return await PdfEditAgent(ctx.deps.runtime).handle(
+15 -1
View File
@@ -5,10 +5,20 @@ from typing import Annotated
from fastapi import Depends, FastAPI
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
from stirling.agents import (
ExecutionPlanningAgent,
FormAnalyserAgent,
FormFillAgent,
FormFillerAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.api.routes import (
agent_draft_router,
execution_router,
form_fill_router,
orchestrator_router,
pdf_edit_router,
pdf_question_router,
@@ -37,6 +47,9 @@ async def lifespan(fast_api: FastAPI):
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.form_fill_agent = FormFillAgent(runtime)
fast_api.state.form_analyser_agent = FormAnalyserAgent(runtime)
fast_api.state.form_filler_agent = FormFillerAgent(runtime)
yield
@@ -46,6 +59,7 @@ app.include_router(pdf_edit_router)
app.include_router(pdf_question_router)
app.include_router(agent_draft_router)
app.include_router(execution_router)
app.include_router(form_fill_router)
@app.get("/health", response_model=HealthResponse)
+22 -1
View File
@@ -2,7 +2,16 @@ from __future__ import annotations
from fastapi import Request
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
from stirling.agents import (
ExecutionPlanningAgent,
FormAnalyserAgent,
FormFillAgent,
FormFillerAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.services import AppRuntime
@@ -28,3 +37,15 @@ def get_user_spec_agent(request: Request) -> UserSpecAgent:
def get_execution_planning_agent(request: Request) -> ExecutionPlanningAgent:
return request.app.state.execution_planning_agent
def get_form_fill_agent(request: Request) -> FormFillAgent:
return request.app.state.form_fill_agent
def get_form_analyser_agent(request: Request) -> FormAnalyserAgent:
return request.app.state.form_analyser_agent
def get_form_filler_agent(request: Request) -> FormFillerAgent:
return request.app.state.form_filler_agent
@@ -1,5 +1,6 @@
from .agent_drafts import router as agent_draft_router
from .execution import router as execution_router
from .form_fill import router as form_fill_router
from .orchestrator import router as orchestrator_router
from .pdf_edit import router as pdf_edit_router
from .pdf_questions import router as pdf_question_router
@@ -7,6 +8,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_draft_router",
"execution_router",
"form_fill_router",
"orchestrator_router",
"pdf_edit_router",
"pdf_question_router",
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from stirling.agents import FormAnalyserAgent, FormFillAgent, FormFillerAgent
from stirling.api.dependencies import get_form_analyser_agent, get_form_fill_agent, get_form_filler_agent
from stirling.contracts.form_fill import (
DocumentExtractionRequest,
DocumentExtractionResponse,
FormAnalysisRequest,
FormAnalysisResponse,
FormFillBatchRequest,
FormFillBatchResponse,
FormFillRequest,
FormFillResponse,
)
router = APIRouter(prefix="/api/v1/form/ai", tags=["form-fill"])
@router.post("", response_model=FormFillResponse)
async def form_fill(
request: FormFillRequest,
agent: Annotated[FormFillAgent, Depends(get_form_fill_agent)],
) -> FormFillResponse:
return await agent.handle(request)
@router.post("/extract", response_model=DocumentExtractionResponse)
async def extract_from_documents(
request: DocumentExtractionRequest,
agent: Annotated[FormFillAgent, Depends(get_form_fill_agent)],
) -> DocumentExtractionResponse:
return await agent.extract_documents(request)
@router.post("/analyse", response_model=FormAnalysisResponse)
async def analyse_forms(
request: FormAnalysisRequest,
agent: Annotated[FormAnalyserAgent, Depends(get_form_analyser_agent)],
) -> FormAnalysisResponse:
return await agent.analyse(request)
@router.post("/fill-batch", response_model=FormFillBatchResponse)
async def fill_forms_batch(
request: FormFillBatchRequest,
agent: Annotated[FormFillerAgent, Depends(get_form_filler_agent)],
) -> FormFillBatchResponse:
return await agent.fill_batch(request)
+54
View File
@@ -18,6 +18,34 @@ from .execution import (
NextExecutionAction,
ToolCallExecutionAction,
)
from .form_fill import (
AnalysedFileResult,
CleanedLabel,
CrossFileRole,
DetectedRole,
DocumentExtractionRequest,
DocumentExtractionResponse,
DocumentText,
FieldMapping,
FileFieldSet,
FileFillRequest,
FileFillResult,
FormAnalysisRequest,
FormAnalysisResponse,
FormField,
FormFillBatchRequest,
FormFillBatchResponse,
FormFillClarificationResponse,
FormFillRequest,
FormFillResponse,
FormFillResultResponse,
KnowledgeEntry,
KnowledgeUpdateResponse,
MultiProfileExtractionResponse,
ProposedProfile,
RoleConfirmationResponse,
RoleDetectionResult,
)
from .health import HealthResponse
from .orchestrator import OrchestratorRequest, OrchestratorResponse, SupportedCapability, UnsupportedCapabilityResponse
from .pdf_edit import (
@@ -48,17 +76,43 @@ __all__ = [
"AgentSpecStep",
"AiToolAgentStep",
"CannotContinueExecutionAction",
"CleanedLabel",
"ConversationMessage",
"DetectedRole",
"DocumentExtractionRequest",
"DocumentExtractionResponse",
"DocumentText",
"FileFieldSet",
"FileFillRequest",
"FileFillResult",
"FormAnalysisRequest",
"FormAnalysisResponse",
"AnalysedFileResult",
"CrossFileRole",
"FormFillBatchRequest",
"FormFillBatchResponse",
"CompletedExecutionAction",
"FieldMapping",
"FormField",
"FormFillClarificationResponse",
"FormFillRequest",
"FormFillResponse",
"FormFillResultResponse",
"EditCannotDoResponse",
"EditClarificationRequest",
"EditPlanResponse",
"ExecutionContext",
"ExecutionStepResult",
"HealthResponse",
"KnowledgeEntry",
"KnowledgeUpdateResponse",
"MultiProfileExtractionResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
"RoleConfirmationResponse",
"RoleDetectionResult",
"ProposedProfile",
"PdfEditRequest",
"PdfEditResponse",
"PdfQuestionAnswerResponse",
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import Field
from stirling.models import ApiModel
from .common import ConversationMessage
class FormField(ApiModel):
"""PDF form field metadata for AI reasoning. Mirrors Java FormFieldWithCoordinates without widget coordinates."""
name: str
label: str | None = None
type: str
value: str | None = None
options: list[str] | None = None
display_options: list[str] | None = None
required: bool = False
read_only: bool = False
multi_select: bool = False
multiline: bool = False
tooltip: str | None = None
nearby_page_text: str | None = None
class FormFillRequest(ApiModel):
user_message: str
conversation_history: list[ConversationMessage] = Field(default_factory=list)
form_fields: list[FormField] = Field(default_factory=list)
knowledge: dict[str, str] = Field(default_factory=dict)
extracted_document_text: str | None = None
role_override: str | None = None
class FieldMapping(ApiModel):
field_name: str
knowledge_key: str
value: str
class CleanedLabel(ApiModel):
field_name: str
label: str
class KnowledgeEntry(ApiModel):
key: str
value: str
source: str
class DocumentText(ApiModel):
file_name: str
text: str
class DocumentExtractionRequest(ApiModel):
documents: list[DocumentText]
existing_profile_names: list[str] = Field(default_factory=list)
class ProposedProfile(ApiModel):
suggested_name: str
entries: list[KnowledgeEntry]
source_documents: list[str]
class MultiProfileExtractionResponse(ApiModel):
outcome: Literal["multi_profile_extraction"] = "multi_profile_extraction"
proposed_profiles: list[ProposedProfile]
message: str
class DetectedRole(ApiModel):
role_label: str
field_names: list[str]
is_primary_person: bool
class RoleDetectionResult(ApiModel):
detected_roles: list[DetectedRole]
primary_role_label: str | None = None
primary_confidence: float = Field(ge=0.0, le=1.0)
confidence_reasoning: str
class FormFillResultResponse(ApiModel):
outcome: Literal["fill_result"] = "fill_result"
filled_fields: list[FieldMapping]
cleaned_labels: list[CleanedLabel] = Field(default_factory=list)
skipped_field_names: list[str] = Field(default_factory=list)
role_detection: RoleDetectionResult | None = None
message: str
class RoleConfirmationResponse(ApiModel):
outcome: Literal["role_confirmation_needed"] = "role_confirmation_needed"
role_detection: RoleDetectionResult
suggested_primary: str
question: str
provisional_fills: list[FieldMapping]
cleaned_labels: list[CleanedLabel] = Field(default_factory=list)
skipped_field_names: list[str] = Field(default_factory=list)
class KnowledgeUpdateResponse(ApiModel):
outcome: Literal["knowledge_update"] = "knowledge_update"
proposed_entries: list[KnowledgeEntry]
message: str
class FormFillClarificationResponse(ApiModel):
outcome: Literal["form_fill_clarification"] = "form_fill_clarification"
question: str
reason: str
# --- Form Analysis (multi-file) ---
class FileFieldSet(ApiModel):
file_id: str
file_name: str
form_fields: list[FormField] = Field(default_factory=list)
class FormAnalysisRequest(ApiModel):
files: list[FileFieldSet]
class AnalysedFileResult(ApiModel):
file_id: str
file_name: str
detected_roles: list[DetectedRole]
cleaned_labels: list[CleanedLabel] = Field(default_factory=list)
skipped_field_names: list[str] = Field(default_factory=list)
class CrossFileRole(ApiModel):
role_label: str
file_ids: list[str]
field_names_by_file: dict[str, list[str]]
is_primary_person: bool
class FormAnalysisResponse(ApiModel):
per_file: list[AnalysedFileResult]
cross_file_roles: list[CrossFileRole]
message: str
# --- Batch Fill ---
class FileFillRequest(ApiModel):
file_id: str
form_fields: list[FormField] = Field(default_factory=list)
role_label: str
class FormFillBatchRequest(ApiModel):
files: list[FileFillRequest]
knowledge: dict[str, str] = Field(default_factory=dict)
class FileFillResult(ApiModel):
file_id: str
filled_fields: list[FieldMapping]
class FormFillBatchResponse(ApiModel):
outcome: Literal["batch_fill_result"] = "batch_fill_result"
per_file: list[FileFillResult]
message: str
DocumentExtractionResponse = Annotated[
KnowledgeUpdateResponse | MultiProfileExtractionResponse,
Field(discriminator="outcome"),
]
FormFillResponse = Annotated[
FormFillResultResponse | RoleConfirmationResponse | KnowledgeUpdateResponse | FormFillClarificationResponse,
Field(discriminator="outcome"),
]
@@ -9,6 +9,7 @@ from stirling.models import ApiModel
from .agent_drafts import AgentDraftResponse
from .execution import NextExecutionAction
from .form_fill import FormFillResponse
from .pdf_edit import PdfEditResponse
from .pdf_questions import PdfQuestionResponse
@@ -20,6 +21,7 @@ class SupportedCapability(StrEnum):
AGENT_DRAFT = "agent_draft"
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
FORM_FILL = "form_fill"
class OrchestratorRequest(ApiModel):
@@ -34,6 +36,11 @@ class UnsupportedCapabilityResponse(ApiModel):
OrchestratorResponse = Annotated[
PdfEditResponse | PdfQuestionResponse | AgentDraftResponse | NextExecutionAction | UnsupportedCapabilityResponse,
PdfEditResponse
| PdfQuestionResponse
| FormFillResponse
| AgentDraftResponse
| NextExecutionAction
| UnsupportedCapabilityResponse,
Field(discriminator="outcome"),
]
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import pytest
from stirling.agents import FormFillAgent
from stirling.config import AppSettings
from stirling.contracts.form_fill import (
DetectedRole,
FieldMapping,
FormField,
FormFillClarificationResponse,
FormFillRequest,
FormFillResultResponse,
KnowledgeEntry,
KnowledgeUpdateResponse,
RoleConfirmationResponse,
RoleDetectionResult,
)
from stirling.services import build_runtime
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
class StubFormFillAgent(FormFillAgent):
def __init__(
self,
fill_response: FormFillResultResponse | None = None,
extraction_response: KnowledgeUpdateResponse | None = None,
) -> None:
super().__init__(build_runtime(build_test_settings()))
self._fill_response = fill_response
self._extraction_response = extraction_response
async def _run_fill_agent(self, request: FormFillRequest) -> FormFillResultResponse:
assert self._fill_response is not None
return self._fill_response
async def _extract_knowledge(self, request: FormFillRequest) -> KnowledgeUpdateResponse:
assert self._extraction_response is not None
return self._extraction_response
@pytest.mark.anyio
async def test_form_fill_returns_clarification_when_no_fields_or_document() -> None:
agent = FormFillAgent(build_runtime(build_test_settings()))
response = await agent.handle(FormFillRequest(user_message="Fill this form"))
assert isinstance(response, FormFillClarificationResponse)
@pytest.mark.anyio
async def test_form_fill_routes_to_fill_when_fields_provided() -> None:
expected = FormFillResultResponse(
filled_fields=[FieldMapping(field_name="name", knowledge_key="full_name", value="John Doe")],
role_detection=RoleDetectionResult(
detected_roles=[DetectedRole(role_label="Applicant", field_names=["name"], is_primary_person=True)],
primary_role_label="Applicant",
primary_confidence=1.0,
confidence_reasoning="Single section form.",
),
message="Filled 1 field.",
)
agent = StubFormFillAgent(fill_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Fill this form",
form_fields=[FormField(name="name", type="text", label="Full Name")],
knowledge={"full_name": "John Doe"},
)
)
assert isinstance(response, FormFillResultResponse)
assert len(response.filled_fields) == 1
assert response.filled_fields[0].value == "John Doe"
@pytest.mark.anyio
async def test_form_fill_routes_to_extraction_when_document_text_provided() -> None:
expected = KnowledgeUpdateResponse(
proposed_entries=[KnowledgeEntry(key="full_name", value="John Doe", source="extracted from CV")],
message="Extracted 1 entry.",
)
agent = StubFormFillAgent(extraction_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Extract my info",
extracted_document_text="John Doe, Software Engineer, john@example.com",
)
)
assert isinstance(response, KnowledgeUpdateResponse)
assert len(response.proposed_entries) == 1
assert response.proposed_entries[0].key == "full_name"
@pytest.mark.anyio
async def test_form_fill_returns_empty_filled_for_unknown_info() -> None:
expected = FormFillResultResponse(
filled_fields=[],
role_detection=RoleDetectionResult(
detected_roles=[DetectedRole(role_label="Applicant", field_names=["ssn"], is_primary_person=True)],
primary_role_label="Applicant",
primary_confidence=1.0,
confidence_reasoning="Single section form.",
),
message="Could not fill any fields.",
)
agent = StubFormFillAgent(fill_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Fill this form",
form_fields=[FormField(name="ssn", type="text", label="Social Security Number")],
knowledge={},
)
)
assert isinstance(response, FormFillResultResponse)
assert len(response.filled_fields) == 0
@pytest.mark.anyio
async def test_form_fill_prefers_fill_when_both_fields_and_document_provided() -> None:
expected = FormFillResultResponse(
filled_fields=[FieldMapping(field_name="email", knowledge_key="email", value="john@example.com")],
role_detection=RoleDetectionResult(
detected_roles=[DetectedRole(role_label="Applicant", field_names=["email"], is_primary_person=True)],
primary_role_label="Applicant",
primary_confidence=1.0,
confidence_reasoning="Single section form.",
),
message="Filled 1 field.",
)
agent = StubFormFillAgent(fill_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Fill this form",
form_fields=[FormField(name="email", type="text", label="Email")],
knowledge={"email": "john@example.com"},
extracted_document_text="Some document text",
)
)
assert isinstance(response, FormFillResultResponse)
@pytest.mark.anyio
async def test_low_confidence_returns_role_confirmation() -> None:
expected = FormFillResultResponse(
filled_fields=[FieldMapping(field_name="client_name", knowledge_key="full_name", value="John Doe")],
role_detection=RoleDetectionResult(
detected_roles=[
DetectedRole(role_label="Client", field_names=["client_name"], is_primary_person=True),
DetectedRole(role_label="Beneficiary", field_names=["beneficiary_name"], is_primary_person=False),
],
primary_role_label="Client",
primary_confidence=0.7,
confidence_reasoning="Field prefixes suggest Client is primary.",
),
message="Filled 1 field.",
)
agent = StubFormFillAgent(fill_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Fill this form",
form_fields=[
FormField(name="client_name", type="text", label="Client Name"),
FormField(name="beneficiary_name", type="text", label="Beneficiary Name"),
],
knowledge={"full_name": "John Doe"},
)
)
assert isinstance(response, RoleConfirmationResponse)
assert response.suggested_primary == "Client"
assert len(response.provisional_fills) == 1
@pytest.mark.anyio
async def test_preference_match_skips_confirmation() -> None:
expected = FormFillResultResponse(
filled_fields=[FieldMapping(field_name="client_name", knowledge_key="full_name", value="John Doe")],
role_detection=RoleDetectionResult(
detected_roles=[
DetectedRole(role_label="Client", field_names=["client_name"], is_primary_person=True),
DetectedRole(role_label="Beneficiary", field_names=["beneficiary_name"], is_primary_person=False),
],
primary_role_label="Client",
primary_confidence=0.7,
confidence_reasoning="Field prefixes suggest Client is primary.",
),
message="Filled 1 field.",
)
agent = StubFormFillAgent(fill_response=expected)
response = await agent.handle(
FormFillRequest(
user_message="Fill this form",
form_fields=[
FormField(name="client_name", type="text", label="Client Name"),
FormField(name="beneficiary_name", type="text", label="Beneficiary Name"),
],
knowledge={"full_name": "John Doe", "_role_preference": "client"},
)
)
# Preference matches "Client", so no confirmation needed
assert isinstance(response, FormFillResultResponse)
+25
View File
@@ -3,6 +3,7 @@ from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import (
get_execution_planning_agent,
get_form_fill_agent,
get_orchestrator_agent,
get_pdf_edit_agent,
get_pdf_question_agent,
@@ -24,6 +25,10 @@ from stirling.contracts import (
PdfQuestionRequest,
UnsupportedCapabilityResponse,
)
from stirling.contracts.form_fill import (
FormFillClarificationResponse,
FormFillRequest,
)
from stirling.models.tool_models import RotateParams
@@ -67,6 +72,14 @@ class StubUserSpecAgent:
return AgentRevisionResponse(draft=request.current_draft)
class StubFormFillAgent:
async def handle(self, request: FormFillRequest) -> FormFillClarificationResponse:
return FormFillClarificationResponse(
question="Please upload a PDF form.",
reason=request.user_message,
)
class StubExecutionPlanningAgent:
async def next_action(self, request: AgentExecutionRequest) -> CannotContinueExecutionAction:
return CannotContinueExecutionAction(reason=str(request.current_step_index))
@@ -95,6 +108,10 @@ def override_user_spec_agent() -> StubUserSpecAgent:
return StubUserSpecAgent()
def override_form_fill_agent() -> StubFormFillAgent:
return StubFormFillAgent()
def override_execution_agent() -> StubExecutionPlanningAgent:
return StubExecutionPlanningAgent()
@@ -105,6 +122,7 @@ app.dependency_overrides[get_pdf_edit_agent] = override_pdf_edit_agent
app.dependency_overrides[get_pdf_question_agent] = override_pdf_question_agent
app.dependency_overrides[get_user_spec_agent] = override_user_spec_agent
app.dependency_overrides[get_execution_planning_agent] = override_execution_agent
app.dependency_overrides[get_form_fill_agent] = override_form_fill_agent
def test_health_route() -> None:
@@ -166,6 +184,13 @@ def test_agent_revise_route() -> None:
assert response.json()["outcome"] == "draft"
def test_form_fill_route() -> None:
response = client.post("/api/v1/form/ai", json={"userMessage": "fill my form"})
assert response.status_code == 200
assert response.json()["outcome"] == "form_fill_clarification"
def test_next_action_route() -> None:
response = client.post(
"/api/v1/agents/next-action",
+57
View File
@@ -13,6 +13,17 @@ from stirling.contracts import (
PdfQuestionAnswerResponse,
ToolOperationStep,
)
from stirling.contracts.form_fill import (
DetectedRole,
FieldMapping,
FormFillClarificationResponse,
FormFillRequest,
FormFillResultResponse,
KnowledgeEntry,
KnowledgeUpdateResponse,
RoleConfirmationResponse,
RoleDetectionResult,
)
from stirling.models.tool_models import OperationId, RotateParams
@@ -66,6 +77,52 @@ def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
def test_form_fill_request_serializes_to_camel_case() -> None:
request = FormFillRequest(user_message="Fill this", knowledge={"name": "John"})
data = request.model_dump(by_alias=True)
assert "userMessage" in data
assert "formFields" in data
assert "extractedDocumentText" in data
def test_form_fill_result_response_discriminator() -> None:
fill = FormFillResultResponse(
filled_fields=[FieldMapping(field_name="name", knowledge_key="full_name", value="John")],
message="Done.",
)
assert fill.outcome == "fill_result"
update = KnowledgeUpdateResponse(
proposed_entries=[KnowledgeEntry(key="name", value="John", source="CV")],
message="Extracted.",
)
assert update.outcome == "knowledge_update"
clarification = FormFillClarificationResponse(question="What?", reason="Missing info.")
assert clarification.outcome == "form_fill_clarification"
def test_role_confirmation_response() -> None:
confirmation = RoleConfirmationResponse(
role_detection=RoleDetectionResult(
detected_roles=[
DetectedRole(role_label="Client", field_names=["name"], is_primary_person=True),
DetectedRole(role_label="Beneficiary", field_names=["ben_name"], is_primary_person=False),
],
primary_role_label="Client",
primary_confidence=0.75,
confidence_reasoning="Field prefixes.",
),
suggested_primary="Client",
question="Are you the Client?",
provisional_fills=[FieldMapping(field_name="name", knowledge_key="full_name", value="John")],
)
assert confirmation.outcome == "role_confirmation_needed"
assert confirmation.suggested_primary == "Client"
assert len(confirmation.provisional_fills) == 1
def test_app_settings_accepts_model_configuration() -> None:
settings = AppSettings(
smart_model_name="claude-sonnet-4-5-20250929",
@@ -3929,6 +3929,10 @@ title = "Automate"
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
[home.aiFormFill]
desc = "Use AI to automatically fill PDF form fields from your knowledge base"
title = "AI Form Fill"
[home.autoRename]
desc = "Auto renames a PDF file based on its detected header"
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
@@ -8527,3 +8531,27 @@ description = "Google API Key for Google Picker API from Google Cloud Console"
[provider.googledrive.appId]
label = "App ID"
description = "Google Drive App ID from Google Cloud Console"
[aiFormFill]
title = "AI Form Fill"
setupDesc = "Upload a PDF form and let AI fill it using your stored knowledge."
noFile = "Open a PDF form to get started."
autoFillButton = "Auto-Fill with AI"
filling = "AI is filling your form..."
applying = "Generating filled PDF..."
filledCount = "Filled {{filled}} of {{total}} fields"
filledSection = "Filled Fields"
unfilledSection = "Needs Your Input"
saveApply = "Save & Apply"
reset = "Start Over"
knowledgeSummary = "{{count}} entries stored"
knowledgeEmpty = "No knowledge stored yet"
knowledgeClear = "Clear All"
editHint = "Edit remaining fields directly on the PDF or in the form sidebar."
roleConfirmQuestion = "Are you the \"{{role}}\" in this form?"
roleSelectQuestion = "Which section is yours?"
rememberRole = "Remember this for similar forms"
notMe = "No, let me choose"
yesConfirm = "Yes, fill as {{role}}"
fillAsRole = "Fill as {{role}}"
rerun = "Re-run AI Fill"
@@ -40,6 +40,7 @@ import SingleLargePage from "@app/tools/SingleLargePage";
import PageLayout from "@app/tools/PageLayout";
import UnlockPdfForms from "@app/tools/UnlockPdfForms";
import FormFill from "@app/tools/formFill/FormFill";
import AiFormFill from "@app/tools/aiFormFill/AiFormFill";
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
import RemoveImage from "@app/tools/RemoveImage";
import CertSign from "@app/tools/CertSign";
@@ -375,6 +376,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'],
},
aiFormFill: {
icon: <LocalIcon icon="auto-awesome" width="1.5rem" height="1.5rem" />,
name: t('home.aiFormFill.title', 'AI Form Fill'),
component: AiFormFill,
description: t('home.aiFormFill.desc', 'Use AI to automatically fill PDF form fields from your knowledge base'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer' as const,
endpoints: [],
automationSettings: null,
supportsAutomate: false,
synonyms: ['ai', 'auto', 'form', 'fill', 'smart', 'intelligent', 'knowledge'],
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
name: t("home.changePermissions.title", "Change Permissions"),
@@ -0,0 +1,39 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.header {
padding: 0.75rem;
border-bottom: 1px solid var(--mantine-color-default-border);
flex-shrink: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.footer {
padding: 0.75rem;
border-top: 1px solid var(--mantine-color-default-border);
flex-shrink: 0;
}
.fieldCard {
padding: 0.5rem;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
}
.filledField {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-green-light);
}
@@ -0,0 +1,472 @@
/**
* AI Form Fill tool — analyses forms (role detection, label cleanup),
* then fills using profiles. Supports multi-file bulk operations.
*/
import { useMemo, useCallback, useState } from 'react';
import {
Stack,
Text,
Button,
Progress,
Loader,
Alert,
Badge,
Divider,
Group,
Select,
SegmentedControl,
Checkbox,
} from '@mantine/core';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import AnalyticsIcon from '@mui/icons-material/Analytics';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { useTranslation } from 'react-i18next';
import { useFileState } from '@app/contexts/FileContext';
import { isStirlingFile } from '@app/types/fileContext';
import type { BaseToolProps, ToolComponent } from '@app/types/tool';
import { useKnowledgeStore } from './useKnowledgeStore';
import { useFormAnalysis } from './useFormAnalysis';
import { useBatchFormFillFlow } from './useBatchFormFillFlow';
import { ProfileManagementModal } from './ProfileManagementModal';
import { RoleSection } from './RoleSection';
import { FillPreview } from './FillPreview';
import { usePassiveLearning } from './usePassiveLearning';
import { checkExpiryDates, checkCrossFormConsistency } from './workflowTemplates';
import type { CleanedLabel } from './types';
import styles from './AiFormFill.module.css';
const AiFormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectors, state: fileState } = useFileState();
const knowledge = useKnowledgeStore();
const analysis = useFormAnalysis(knowledge);
const batchFill = useBatchFormFillFlow(analysis, knowledge);
const passiveLearning = usePassiveLearning();
const [modalOpened, setModalOpened] = useState(false);
const [viewMode, setViewMode] = useState<string>('roles');
// Expiry warnings
const expiryWarnings = useMemo(
() => checkExpiryDates(knowledge.entityStore.entities),
[knowledge.entityStore.entities]
);
const allFiles = useMemo(() => {
const files = selectors.getFiles();
return files.filter((f) => isStirlingFile(f));
}, [selectors, fileState.files.ids]);
const fileNames = useMemo(() => {
const map: Record<string, string> = {};
for (const f of allFiles) {
if (isStirlingFile(f)) map[f.fileId] = f.name;
}
return map;
}, [allFiles]);
const handleAnalyse = useCallback(() => {
if (allFiles.length > 0) {
analysis.analyseAllFiles(allFiles as any);
}
}, [allFiles, analysis.analyseAllFiles]);
const handleFill = useCallback(() => {
if (allFiles.length > 0) {
batchFill.fillAllFiles(allFiles as any);
}
}, [allFiles, batchFill.fillAllFiles]);
const handleReset = useCallback(() => {
analysis.reset();
batchFill.reset();
}, [analysis.reset, batchFill.reset]);
const modal = <ProfileManagementModal opened={modalOpened} onClose={() => setModalOpened(false)} knowledge={knowledge} />;
// Build lookup maps from analysis
const cleanedLabelsByFile = useMemo(() => {
const map: Record<string, CleanedLabel[]> = {};
for (const pf of analysis.analysis?.perFile || []) {
map[pf.fileId] = pf.cleanedLabels;
}
return map;
}, [analysis.analysis]);
const skippedFieldsByFile = useMemo(() => {
const map: Record<string, Set<string>> = {};
for (const pf of analysis.analysis?.perFile || []) {
map[pf.fileId] = new Set(pf.skippedFieldNames);
}
return map;
}, [analysis.analysis]);
// ── Idle / Setup ──
if (analysis.phase === 'idle' || analysis.phase === 'error') {
return (
<>{modal}
<div className={styles.root}>
<div className={styles.header}>
<Group gap={8} mb="xs">
<AutoAwesomeIcon sx={{ fontSize: 20 }} />
<Text fw={600} size="sm">{t('aiFormFill.title', 'AI Form Fill')}</Text>
</Group>
<Group gap={6}>
<Select
size="xs"
data={knowledge.profileNames}
value={knowledge.activeProfileName}
onChange={(v) => v && knowledge.setActiveProfile(v)}
styles={{ input: { fontSize: '0.8125rem' } }}
style={{ flex: 1 }}
/>
<Badge size="sm" variant="light">{knowledge.entryCount}</Badge>
<Button variant="subtle" size="compact-xs" onClick={() => setModalOpened(true)}>
Manage
</Button>
</Group>
</div>
<div className={styles.content}>
<Stack gap="md">
{analysis.error && (
<Alert icon={<WarningAmberIcon />} color="red" variant="light">{analysis.error}</Alert>
)}
<Text size="sm" c="dimmed">
{allFiles.length > 0
? `${allFiles.length} file${allFiles.length > 1 ? 's' : ''} loaded. Analyse to detect form roles and fields.`
: t('aiFormFill.noFile', 'Open PDF forms to get started.')}
</Text>
{/* Expiry warnings */}
{expiryWarnings.length > 0 && (
<Alert color={expiryWarnings.some((w) => w.isExpired) ? 'red' : 'yellow'} variant="light">
<Stack gap={2}>
<Text size="xs" fw={600}>
{expiryWarnings.some((w) => w.isExpired) ? 'Expired credentials!' : 'Expiring soon'}
</Text>
{expiryWarnings.map((w, i) => (
<Text key={i} size="xs">
{w.entityName} — {w.fieldKey}: {w.isExpired ? `expired ${Math.abs(w.daysUntilExpiry)} days ago` : `expires in ${w.daysUntilExpiry} days`}
</Text>
))}
</Stack>
</Alert>
)}
</Stack>
</div>
<div className={styles.footer}>
<Button
fullWidth
leftSection={<AnalyticsIcon sx={{ fontSize: 18 }} />}
onClick={handleAnalyse}
disabled={allFiles.length === 0}
>
Analyse Forms
</Button>
</div>
</div>
</>
);
}
// ── Fetching fields / Analysing ──
if (analysis.phase === 'fetching_fields' || analysis.phase === 'analysing') {
return (
<>{modal}
<div className={styles.root}>
<div className={styles.content} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="md">
<Loader size="lg" />
<Text size="sm" c="dimmed">
{analysis.phase === 'fetching_fields'
? 'Extracting form fields...'
: 'AI is analysing your forms...'}
</Text>
</Stack>
</div>
</div>
</>
);
}
// ── Filling / Applying ──
if (batchFill.phase === 'filling' || batchFill.phase === 'applying') {
return (
<>{modal}
<div className={styles.root}>
<div className={styles.content} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="md">
<Loader size="lg" />
<Text size="sm" c="dimmed">
{batchFill.phase === 'filling' ? 'AI is filling your forms...' : 'Applying fills...'}
</Text>
</Stack>
</div>
</div>
</>
);
}
// ── Fill Preview ──
if (batchFill.phase === 'preview') {
return (
<>{modal}
<div className={styles.root}>
<div className={styles.header}>
<Group gap={8} mb="xs">
<AutoAwesomeIcon sx={{ fontSize: 20 }} />
<Text fw={600} size="sm">Review Fills</Text>
</Group>
<Text size="xs" c="dimmed">
Review proposed values before applying. Uncheck fields to skip, click values to edit.
</Text>
</div>
<div className={styles.content}>
<FillPreview
fields={batchFill.previewFields}
fileNames={fileNames}
onToggle={batchFill.togglePreviewField}
onEdit={batchFill.editPreviewField}
/>
</div>
<div className={styles.footer}>
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={handleReset}>
Cancel
</Button>
<Button
size="xs"
style={{ flex: 1 }}
onClick={() => batchFill.applyPreview(allFiles as any)}
disabled={!batchFill.previewFields.some((f) => f.accepted)}
>
Apply {batchFill.previewFields.filter((f) => f.accepted).length} Fields
</Button>
</Group>
</div>
</div>
</>
);
}
// ── Fill Complete ──
if (batchFill.phase === 'done') {
const totalFilled = batchFill.results.reduce((sum, r) => sum + r.filledFields.length, 0);
// Consistency check
const consistencyIssues = checkCrossFormConsistency(
batchFill.results.map((r) => ({
fileId: r.fileId,
fileName: fileNames[r.fileId] || r.fileId,
filledFields: r.filledFields,
}))
);
return (
<>{modal}
<div className={styles.root}>
<div className={styles.header}>
<Group gap={8} mb="xs">
<CheckCircleIcon sx={{ fontSize: 20, color: 'var(--mantine-color-green-6)' }} />
<Text fw={600} size="sm">Fill Complete</Text>
</Group>
</div>
<div className={styles.content}>
<Stack gap="sm">
<Text size="sm">
Filled {totalFilled} fields across {batchFill.results.length} files.
</Text>
{batchFill.message && <Text size="xs" c="dimmed">{batchFill.message}</Text>}
{batchFill.error && (
<Alert icon={<WarningAmberIcon />} color="red" variant="light">{batchFill.error}</Alert>
)}
{/* Consistency warnings */}
{consistencyIssues.length > 0 && (
<>
<Divider />
<Text size="xs" fw={600} c="orange">
Consistency Issues ({consistencyIssues.length})
</Text>
{consistencyIssues.map((issue, i) => (
<Alert key={i} color="yellow" variant="light" p="xs">
<Text size="xs">
Field "{issue.fieldLabel}" has different values across files:
{issue.values.map((v, j) => (
<Text key={j} size="xs" c="dimmed" component="span"> {v.fileName}: "{v.value}"</Text>
))}
</Text>
</Alert>
))}
</>
)}
{/* Passive learning — detect manual edits */}
<Divider />
<Button
size="xs"
variant="light"
onClick={() => passiveLearning.detectChanges(batchFill.previewFields, knowledge.entityStore)}
>
Check for Manual Edits to Learn
</Button>
{passiveLearning.hasChanges && (
<Stack gap={4}>
<Text size="xs" fw={600}>
Detected {passiveLearning.learnedFields.length} manual changes:
</Text>
{passiveLearning.learnedFields.map((field, i) => (
<Group key={i} gap={6} wrap="nowrap">
<Checkbox
size="xs"
checked={field.accepted}
onChange={() => passiveLearning.toggleField(i)}
/>
<Text size="xs" style={{ flex: 1 }}>
{field.fieldName}: "{field.newValue}"
</Text>
<Badge size="xs" variant="light">
→ {field.suggestedEntityName || 'Unknown'}
</Badge>
</Group>
))}
<Button
size="xs"
variant="light"
color="green"
onClick={() => passiveLearning.commitLearned(knowledge.entityStore)}
disabled={!passiveLearning.learnedFields.some((f) => f.accepted)}
>
Save {passiveLearning.learnedFields.filter((f) => f.accepted).length} to Entities
</Button>
</Stack>
)}
</Stack>
</div>
<div className={styles.footer}>
<Button fullWidth variant="subtle" onClick={handleReset}>
Start Over
</Button>
</div>
</div>
</>
);
}
// ── Analysis Review (main state) ──
const roles = analysis.analysis?.crossFileRoles || [];
const hasUnassignedRoles = roles.some((r) => !analysis.roleProfileMap[r.roleLabel]);
return (
<>{modal}
<div className={styles.root}>
<div className={styles.header}>
<Group gap={8} mb="xs">
<AutoAwesomeIcon sx={{ fontSize: 20 }} />
<Text fw={600} size="sm">{t('aiFormFill.title', 'AI Form Fill')}</Text>
</Group>
<Group gap={6} mb="xs">
<Select
size="xs"
data={knowledge.entityStore.selectData}
value={knowledge.entityStore.defaultEntityId}
onChange={(v) => v && knowledge.entityStore.setDefaultEntity(v)}
searchable
styles={{ input: { fontSize: '0.8125rem' } }}
style={{ flex: 1 }}
/>
<Button variant="subtle" size="compact-xs" onClick={() => setModalOpened(true)}>
Manage
</Button>
</Group>
<Text size="xs" c="dimmed" mb={4}>
{roles.length} role{roles.length !== 1 ? 's' : ''} detected across {allFiles.length} file{allFiles.length !== 1 ? 's' : ''}
</Text>
{analysis.analysis?.message && (
<Text size="xs" c="dimmed">{analysis.analysis.message}</Text>
)}
<SegmentedControl
size="xs"
value={viewMode}
onChange={setViewMode}
data={[
{ label: 'By Role', value: 'roles' },
{ label: 'Original Order', value: 'original' },
]}
fullWidth
mt="xs"
/>
</div>
<div className={styles.content}>
<Stack gap="sm">
{viewMode === 'roles' ? (
// Role-grouped view
roles.map((role) => (
<RoleSection
key={role.roleLabel}
role={role}
entitySelectData={knowledge.entityStore.selectData}
selectedEntityId={analysis.roleProfileMap[role.roleLabel]}
onEntityChange={(id) => analysis.setRoleProfile(role.roleLabel, id)}
fieldsByFile={analysis.fieldsByFile}
cleanedLabelsByFile={cleanedLabelsByFile}
skippedFieldsByFile={skippedFieldsByFile}
fileNames={fileNames}
fileRoleOverrides={analysis.fileRoleOverrides}
onFileOverride={(fileId, entityId) =>
entityId
? analysis.setFileRoleOverride(fileId, role.roleLabel, entityId)
: analysis.clearFileRoleOverride(fileId, role.roleLabel)
}
/>
))
) : (
// Original order — flat field list per file
allFiles.map((file) => {
if (!isStirlingFile(file)) return null;
const fields = analysis.fieldsByFile[file.fileId] || [];
const skipped = skippedFieldsByFile[file.fileId] || new Set();
const labels = cleanedLabelsByFile[file.fileId] || [];
const labelMap: Record<string, string> = {};
for (const cl of labels) labelMap[cl.fieldName] = cl.label;
return (
<Stack key={file.fileId} gap={4}>
<Text size="xs" fw={600}>{file.name}</Text>
{fields
.filter((f) => !skipped.has(f.name) && !f.readOnly)
.map((f) => (
<Text key={f.name} size="xs" c="dimmed" pl="sm">
{labelMap[f.name] || f.label || f.name}
</Text>
))}
<Divider />
</Stack>
);
})
)}
</Stack>
</div>
<div className={styles.footer}>
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={handleReset}>
Start Over
</Button>
<Button
size="xs"
style={{ flex: 1 }}
leftSection={<AutoAwesomeIcon sx={{ fontSize: 16 }} />}
onClick={handleFill}
disabled={hasUnassignedRoles}
>
Fill {allFiles.length > 1 ? `${allFiles.length} Forms` : 'Form'}
</Button>
</Group>
</div>
</div>
</>
);
};
export default AiFormFill as ToolComponent;
@@ -0,0 +1,331 @@
/**
* Entities tab — two-column layout: entity list grouped by type (left) + fields table (right).
*/
import { useState, useCallback } from 'react';
import {
Stack,
Group,
Text,
Button,
TextInput,
ActionIcon,
NavLink,
Badge,
Table,
Menu,
Select,
} from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import AddIcon from '@mui/icons-material/Add';
import { ALL_ENTITY_TYPES, ENTITY_TYPE_LABELS, type EntityType } from './entityTypes';
import type { EntityStore } from './useEntityStore';
import {
exportEntitiesToJson,
downloadJson,
importEntitiesFromJson,
importCsvToStore,
} from './entityImportExport';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
export function EntitiesTab({ store }: { store: EntityStore }) {
const [selectedId, setSelectedId] = useState<string | null>(store.defaultEntityId);
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState<EntityType>('person');
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [importMessage, setImportMessage] = useState<string | null>(null);
const selectedEntity = selectedId ? store.getEntity(selectedId) : undefined;
const entries = selectedEntity
? Object.entries(selectedEntity.fields).filter(([k]) => !k.startsWith('_'))
: [];
const handleCreate = useCallback(() => {
const name = newName.trim();
if (name) {
const entity = store.createEntity(newType, name);
setSelectedId(entity.id);
setNewName('');
}
}, [newName, newType, store]);
const handleRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
store.updateEntity(renamingId, { name: renameValue.trim() });
}
setRenamingId(null);
setRenameValue('');
}, [renamingId, renameValue, store]);
const handleSaveEdit = useCallback(
(key: string) => {
if (selectedId) store.setField(selectedId, key, editValue);
setEditingKey(null);
setEditValue('');
},
[selectedId, editValue, store]
);
const typeSelectData = ALL_ENTITY_TYPES.map((t) => ({
value: t,
label: ENTITY_TYPE_LABELS[t],
}));
return (
<div style={{ display: 'flex', gap: '1rem', height: '100%', minHeight: 400 }}>
{/* Left: entity list grouped by type */}
<div style={{ width: '35%', borderRight: '1px solid var(--mantine-color-default-border)', paddingRight: '0.75rem', overflowY: 'auto' }}>
<Stack gap="xs">
{/* Create new entity */}
<Group gap={4}>
<Select
size="xs"
data={typeSelectData}
value={newType}
onChange={(v) => v && setNewType(v as EntityType)}
styles={{ input: { fontSize: '0.75rem' } }}
style={{ width: 100 }}
/>
<TextInput
size="xs"
placeholder="Name..."
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
<ActionIcon size="sm" variant="light" onClick={handleCreate} disabled={!newName.trim()}>
<AddIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
{/* Entity list grouped by type */}
{ALL_ENTITY_TYPES.map((type) => {
const typeEntities = store.entitiesByType[type];
if (typeEntities.length === 0) return null;
return (
<Stack key={type} gap={2}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{ENTITY_TYPE_LABELS[type]}
</Text>
{typeEntities.map((entity) => (
<Group key={entity.id} gap={0} wrap="nowrap">
{renamingId === entity.id ? (
<TextInput
size="xs"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onBlur={handleRename}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
autoFocus
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
) : (
<NavLink
label={entity.name}
active={selectedId === entity.id}
onClick={() => setSelectedId(entity.id)}
style={{ flex: 1, borderRadius: 'var(--mantine-radius-sm)' }}
rightSection={
<Badge size="xs" variant="light">
{Object.keys(entity.fields).filter((k) => !k.startsWith('_')).length}
</Badge>
}
/>
)}
<Menu position="bottom-end" withArrow>
<Menu.Target>
<ActionIcon size="xs" variant="subtle">
<MoreVertIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setRenamingId(entity.id);
setRenameValue(entity.name);
}}
>
Rename
</Menu.Item>
<Menu.Item
leftSection={<ContentCopyIcon sx={{ fontSize: 14 }} />}
onClick={() => {
const dup = store.duplicateEntity(entity.id, `${entity.name} (copy)`);
setSelectedId(dup.id);
}}
>
Duplicate
</Menu.Item>
{store.entities.length > 1 && (
<Menu.Item
color="red"
leftSection={<DeleteIcon sx={{ fontSize: 14 }} />}
onClick={() => {
store.deleteEntity(entity.id);
if (selectedId === entity.id) setSelectedId(null);
}}
>
Delete
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
))}
</Stack>
);
})}
</Stack>
</div>
{/* Right: fields for selected entity */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{!selectedEntity ? (
<Text size="sm" c="dimmed" ta="center" mt="xl">
Select an entity to view its fields.
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" mt="xl">
No fields in this entity yet. Import a document or add fields manually.
</Text>
) : (
<Stack gap="xs">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Field</Table.Th>
<Table.Th>Value</Table.Th>
<Table.Th w={40} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{entries.map(([key, val]) => (
<Table.Tr key={key}>
<Table.Td><Text size="xs">{key}</Text></Table.Td>
<Table.Td>
{editingKey === key ? (
<TextInput
size="xs"
value={editValue}
onChange={(e) => setEditValue(e.currentTarget.value)}
onBlur={() => handleSaveEdit(key)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveEdit(key)}
autoFocus
styles={{ input: { fontSize: '0.8125rem' } }}
/>
) : (
<Text
size="xs"
style={{ cursor: 'pointer' }}
onClick={() => {
setEditingKey(key);
setEditValue(val);
}}
>
{val}
</Text>
)}
</Table.Td>
<Table.Td>
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={() => store.removeField(selectedEntity.id, key)}
>
<DeleteIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Button size="xs" variant="subtle" color="red" onClick={() => store.clearFields(selectedEntity.id)}>
Clear All Fields
</Button>
</Stack>
)}
{/* Import/Export buttons */}
<Stack gap="xs" mt="auto" pt="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
{importMessage && (
<Text size="xs" c={importMessage.startsWith('Error') ? 'red' : 'green'}>{importMessage}</Text>
)}
<Group gap="xs">
<Button
size="xs"
variant="light"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
onClick={() => {
const json = exportEntitiesToJson(store.entities);
downloadJson(json, `stirling-entities-${new Date().toISOString().split('T')[0]}.json`);
}}
>
Export JSON
</Button>
<Button
size="xs"
variant="light"
leftSection={<FileUploadIcon sx={{ fontSize: 14 }} />}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
file.text().then((text) => {
const result = importEntitiesFromJson(text, store);
setImportMessage(
result.errors.length > 0
? `Imported ${result.imported}, errors: ${result.errors.join('; ')}`
: `Imported ${result.imported} entities.`
);
});
};
input.click();
}}
>
Import JSON
</Button>
<Button
size="xs"
variant="light"
leftSection={<FileUploadIcon sx={{ fontSize: 14 }} />}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.csv';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
file.text().then((text) => {
const result = importCsvToStore(text, newType, store);
setImportMessage(
result.errors.length > 0
? `Imported ${result.imported}, errors: ${result.errors.join('; ')}`
: `Imported ${result.imported} entities as ${ENTITY_TYPE_LABELS[newType]}.`
);
});
};
input.click();
}}
>
Import CSV
</Button>
</Group>
</Stack>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
/**
* Fill Preview — shows proposed field values before applying.
* Each field shows: field name, value, source entity, accept/edit/reject toggle.
*/
import { useCallback } from 'react';
import {
Stack,
Group,
Text,
Checkbox,
TextInput,
Badge,
Divider,
} from '@mantine/core';
import type { PreviewField } from './useBatchFormFillFlow';
function cleanPdfText(text: string): string {
let cleaned = text;
cleaned = cleaned.replace(/:plus:/g, ' ');
cleaned = cleaned.replace(/\+/g, ' ');
cleaned = cleaned.replace(/&amp;/g, '&');
cleaned = cleaned.replace(/&#039;/g, "'");
cleaned = cleaned.replace(/&quot;/g, '"');
cleaned = cleaned.replace(/&lt;/g, '<');
cleaned = cleaned.replace(/&gt;/g, '>');
try { cleaned = decodeURIComponent(cleaned); } catch { /* ignore */ }
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
interface FillPreviewProps {
fields: PreviewField[];
fileNames: Record<string, string>;
onToggle: (index: number) => void;
onEdit: (index: number, value: string) => void;
}
export function FillPreview({ fields, fileNames, onToggle, onEdit }: FillPreviewProps) {
// Group by file
const fileGroups: Record<string, { fields: Array<PreviewField & { index: number }> }> = {};
fields.forEach((field, index) => {
if (!fileGroups[field.fileId]) fileGroups[field.fileId] = { fields: [] };
fileGroups[field.fileId].fields.push({ ...field, index });
});
const acceptedCount = fields.filter((f) => f.accepted).length;
return (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{acceptedCount} of {fields.length} fields will be applied. Uncheck to skip, click value to edit.
</Text>
{Object.entries(fileGroups).map(([fileId, group]) => (
<Stack key={fileId} gap={4}>
<Text size="xs" fw={600}>{cleanPdfText(fileNames[fileId] || fileId)}</Text>
{group.fields.map((field) => (
<PreviewFieldRow
key={field.index}
field={field}
index={field.index}
onToggle={onToggle}
onEdit={onEdit}
/>
))}
<Divider />
</Stack>
))}
</Stack>
);
}
function PreviewFieldRow({
field,
index,
onToggle,
onEdit,
}: {
field: PreviewField;
index: number;
onToggle: (index: number) => void;
onEdit: (index: number, value: string) => void;
}) {
const handleEdit = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => onEdit(index, e.currentTarget.value),
[index, onEdit]
);
return (
<Group gap={6} wrap="nowrap" pl="xs">
<Checkbox
size="xs"
checked={field.accepted}
onChange={() => onToggle(index)}
/>
<Text size="xs" w={120} style={{ flexShrink: 0 }} c={field.accepted ? undefined : 'dimmed'}>
{cleanPdfText(field.fieldName)}
</Text>
<TextInput
size="xs"
value={field.value}
onChange={handleEdit}
disabled={!field.accepted}
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.75rem' } }}
/>
<Badge size="xs" variant="light" color="blue" style={{ flexShrink: 0 }}>
{field.entityName}
</Badge>
</Group>
);
}
@@ -0,0 +1,196 @@
/**
* Import tab — document upload dropzone + AI extraction + review UI.
*/
import { useCallback } from 'react';
import {
Stack,
Text,
Button,
Progress,
Loader,
Alert,
Checkbox,
TextInput,
Group,
Paper,
Badge,
} from '@mantine/core';
import { Dropzone, MIME_TYPES } from '@mantine/dropzone';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import type { DocumentImport } from './useDocumentImport';
import type { KnowledgeStore } from './useKnowledgeStore';
interface ImportTabProps {
documentImport: DocumentImport;
knowledge: KnowledgeStore;
}
export function ImportTab({ documentImport, knowledge }: ImportTabProps) {
const { state, extractionProgress, proposedProfiles, error } = documentImport;
const handleDrop = useCallback(
(files: File[]) => {
if (files.length > 0) {
const entityNames = knowledge.entityStore.entities.map((e) => e.name);
documentImport.startImport(files, entityNames);
}
},
[documentImport]
);
const handleCommit = useCallback(() => {
documentImport.acceptAndCommit(knowledge);
}, [documentImport, knowledge]);
// Idle — show dropzone
if (state === 'idle') {
return (
<Stack gap="md">
<Dropzone
onDrop={handleDrop}
accept={[MIME_TYPES.pdf]}
multiple
styles={{
root: {
minHeight: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-md)',
cursor: 'pointer',
},
}}
>
<Stack align="center" gap="xs">
<UploadFileIcon sx={{ fontSize: 40, color: 'var(--mantine-color-dimmed)' }} />
<Text size="sm" c="dimmed" ta="center">
Drop PDF files here or click to browse
</Text>
<Text size="xs" c="dimmed">
Upload CVs, resumes, IDs, or any documents containing personal information
</Text>
</Stack>
</Dropzone>
</Stack>
);
}
// Extracting text
if (state === 'extracting_text') {
return (
<Stack align="center" gap="md" mt="xl">
<Loader size="lg" />
<Text size="sm" c="dimmed">
Extracting text from {extractionProgress.total} file{extractionProgress.total > 1 ? 's' : ''}...
</Text>
<Progress
value={(extractionProgress.completed / extractionProgress.total) * 100}
size="sm"
style={{ width: '60%' }}
/>
</Stack>
);
}
// Calling AI
if (state === 'calling_ai') {
return (
<Stack align="center" gap="md" mt="xl">
<Loader size="lg" />
<Text size="sm" c="dimmed">AI is analysing your documents...</Text>
</Stack>
);
}
// Error
if (state === 'error') {
return (
<Stack gap="md" mt="md">
<Alert icon={<WarningAmberIcon />} color="red" variant="light">
{error || 'Something went wrong.'}
</Alert>
<Button variant="subtle" onClick={documentImport.reset}>
Try Again
</Button>
</Stack>
);
}
// Reviewing
return (
<Stack gap="md">
<Group gap={6}>
<CheckCircleIcon sx={{ fontSize: 18, color: 'var(--mantine-color-green-6)' }} />
<Text size="sm" fw={600}>
Found {proposedProfiles.length} profile{proposedProfiles.length > 1 ? 's' : ''}
</Text>
</Group>
{proposedProfiles.map((profile, pi) => (
<Paper key={pi} withBorder p="sm" radius="sm">
<Group gap="xs" mb="xs">
<Checkbox
size="xs"
checked={profile.accepted}
onChange={() => documentImport.toggleProfile(pi)}
/>
<TextInput
size="xs"
value={profile.name}
onChange={(e) => documentImport.setProfileName(pi, e.currentTarget.value)}
styles={{ input: { fontSize: '0.8125rem', fontWeight: 600 } }}
style={{ flex: 1 }}
/>
{profile.sourceDocuments.length > 0 && (
<Badge size="xs" variant="light">
{profile.sourceDocuments.join(', ')}
</Badge>
)}
</Group>
{profile.accepted && (
<Stack gap={4} ml="xl">
{profile.entries.map((entry, ei) => (
<Group key={ei} gap="xs" wrap="nowrap">
<Checkbox
size="xs"
checked={entry.accepted}
onChange={() => documentImport.toggleEntry(pi, ei)}
/>
<Text size="xs" c="dimmed" w={120} style={{ flexShrink: 0 }}>
{entry.key}
</Text>
<TextInput
size="xs"
value={entry.value}
onChange={(e) => documentImport.editEntryValue(pi, ei, e.currentTarget.value)}
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.8125rem' } }}
disabled={!entry.accepted}
/>
</Group>
))}
</Stack>
)}
</Paper>
))}
<Group gap="xs">
<Button variant="subtle" size="xs" onClick={documentImport.reset}>
Cancel
</Button>
<Button
size="xs"
style={{ flex: 1 }}
onClick={handleCommit}
disabled={!proposedProfiles.some((p) => p.accepted && p.entries.some((e) => e.accepted))}
>
Apply {proposedProfiles.filter((p) => p.accepted).length} Profile{proposedProfiles.filter((p) => p.accepted).length !== 1 ? 's' : ''}
</Button>
</Group>
</Stack>
);
}
@@ -0,0 +1,48 @@
/**
* Entity Management Modal — manage typed entities and import documents.
*/
import { useState } from 'react';
import { Modal, SegmentedControl, Stack } from '@mantine/core';
import { EntitiesTab } from './EntitiesTab';
import { ImportTab } from './ImportTab';
import { useDocumentImport } from './useDocumentImport';
import type { KnowledgeStore } from './useKnowledgeStore';
interface ProfileManagementModalProps {
opened: boolean;
onClose: () => void;
knowledge: KnowledgeStore;
}
export function ProfileManagementModal({ opened, onClose, knowledge }: ProfileManagementModalProps) {
const [activeTab, setActiveTab] = useState<string>('entities');
const documentImport = useDocumentImport();
return (
<Modal
opened={opened}
onClose={onClose}
title="Entity Management"
size="lg"
centered
>
<Stack gap="md">
<SegmentedControl
value={activeTab}
onChange={setActiveTab}
data={[
{ label: 'Entities', value: 'entities' },
{ label: 'Import Documents', value: 'import' },
]}
fullWidth
/>
{activeTab === 'entities' ? (
<EntitiesTab store={knowledge.entityStore} />
) : (
<ImportTab documentImport={documentImport} knowledge={knowledge} />
)}
</Stack>
</Modal>
);
}
@@ -0,0 +1,198 @@
/**
* Profiles tab — two-column layout: profile list (left) + entries table (right).
*/
import { useState, useCallback } from 'react';
import {
Stack,
Group,
Text,
Button,
TextInput,
ActionIcon,
NavLink,
Badge,
Table,
Menu,
} from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
import type { KnowledgeStore } from './useKnowledgeStore';
export function ProfilesTab({ knowledge }: { knowledge: KnowledgeStore }) {
const [newName, setNewName] = useState('');
const [renamingProfile, setRenamingProfile] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const entries = Object.entries(knowledge.entries).filter(([k]) => !k.startsWith('_'));
const handleCreate = useCallback(() => {
const name = newName.trim();
if (name && !knowledge.profileNames.includes(name)) {
knowledge.createProfile(name);
setNewName('');
}
}, [newName, knowledge]);
const handleRename = useCallback(() => {
if (renamingProfile && renameValue.trim() && renameValue.trim() !== renamingProfile) {
knowledge.renameProfile(renamingProfile, renameValue.trim());
}
setRenamingProfile(null);
setRenameValue('');
}, [renamingProfile, renameValue, knowledge]);
const handleSaveEdit = useCallback(
(key: string) => {
knowledge.set(key, editValue);
setEditingKey(null);
setEditValue('');
},
[editValue, knowledge]
);
return (
<div style={{ display: 'flex', gap: '1rem', height: '100%', minHeight: 400 }}>
{/* Left: profile list */}
<div style={{ width: '35%', borderRight: '1px solid var(--mantine-color-default-border)', paddingRight: '0.75rem' }}>
<Stack gap="xs">
<Group gap={4}>
<TextInput
size="xs"
placeholder="New profile..."
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
<ActionIcon size="sm" variant="light" onClick={handleCreate} disabled={!newName.trim()}>
<AddIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
{knowledge.profileNames.map((name) => (
<Group key={name} gap={0} wrap="nowrap">
{renamingProfile === name ? (
<TextInput
size="xs"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onBlur={handleRename}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
autoFocus
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.8125rem' } }}
/>
) : (
<NavLink
label={name}
active={knowledge.activeProfileName === name}
onClick={() => knowledge.setActiveProfile(name)}
style={{ flex: 1, borderRadius: 'var(--mantine-radius-sm)' }}
rightSection={
<Badge size="xs" variant="light">
{Object.keys(knowledge.entries).filter((k) => !k.startsWith('_')).length}
</Badge>
}
/>
)}
<Menu position="bottom-end" withArrow>
<Menu.Target>
<ActionIcon size="xs" variant="subtle">
<MoreVertIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setRenamingProfile(name);
setRenameValue(name);
}}
>
Rename
</Menu.Item>
{knowledge.profileNames.length > 1 && (
<Menu.Item
color="red"
leftSection={<DeleteIcon sx={{ fontSize: 14 }} />}
onClick={() => knowledge.deleteProfile(name)}
>
Delete
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
))}
</Stack>
</div>
{/* Right: entries for active profile */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" mt="xl">
No entries in this profile yet. Upload a document in the Import tab to get started.
</Text>
) : (
<Stack gap="xs">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Field</Table.Th>
<Table.Th>Value</Table.Th>
<Table.Th w={40} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{entries.map(([key, val]) => (
<Table.Tr key={key}>
<Table.Td>
<Text size="xs">{key}</Text>
</Table.Td>
<Table.Td>
{editingKey === key ? (
<TextInput
size="xs"
value={editValue}
onChange={(e) => setEditValue(e.currentTarget.value)}
onBlur={() => handleSaveEdit(key)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveEdit(key)}
autoFocus
styles={{ input: { fontSize: '0.8125rem' } }}
/>
) : (
<Text
size="xs"
style={{ cursor: 'pointer' }}
onClick={() => {
setEditingKey(key);
setEditValue(val);
}}
>
{val}
</Text>
)}
</Table.Td>
<Table.Td>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => knowledge.remove(key)}>
<DeleteIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Button size="xs" variant="subtle" color="red" onClick={knowledge.clear}>
Clear All
</Button>
</Stack>
)}
</div>
</div>
);
}
@@ -0,0 +1,167 @@
/**
* Collapsible role section for the analysis review sidebar.
* Shows a profile selector and expandable per-file field lists.
*/
import { useState } from 'react';
import {
Stack,
Group,
Text,
Select,
Badge,
Collapse,
UnstyledButton,
} from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import PersonIcon from '@mui/icons-material/Person';
import { FIELD_TYPE_ICON, FIELD_TYPE_COLOR } from '@app/tools/formFill/fieldMeta';
import type { FormField, FormFieldType } from '@app/tools/formFill/types';
import type { CrossFileRole, CleanedLabel } from './types';
function cleanPdfText(text: string): string {
let cleaned = text;
cleaned = cleaned.replace(/:plus:/g, ' ');
cleaned = cleaned.replace(/\+/g, ' ');
cleaned = cleaned.replace(/&amp;/g, '&');
cleaned = cleaned.replace(/&#039;/g, "'");
cleaned = cleaned.replace(/&quot;/g, '"');
cleaned = cleaned.replace(/&lt;/g, '<');
cleaned = cleaned.replace(/&gt;/g, '>');
try { cleaned = decodeURIComponent(cleaned); } catch { /* ignore */ }
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
interface SelectGroupItem {
group: string;
items: Array<{ value: string; label: string }>;
}
interface RoleSectionProps {
role: CrossFileRole;
entitySelectData: SelectGroupItem[];
selectedEntityId: string | undefined;
onEntityChange: (entityId: string) => void;
fieldsByFile: Record<string, FormField[]>;
cleanedLabelsByFile: Record<string, CleanedLabel[]>;
skippedFieldsByFile: Record<string, Set<string>>;
fileNames: Record<string, string>;
fileRoleOverrides: Record<string, Record<string, string>>;
onFileOverride: (fileId: string, entityId: string) => void;
}
export function RoleSection({
role,
entitySelectData,
selectedEntityId,
onEntityChange,
fieldsByFile,
cleanedLabelsByFile,
skippedFieldsByFile,
fileNames,
fileRoleOverrides,
onFileOverride,
}: RoleSectionProps) {
const [expanded, setExpanded] = useState(false);
const roleFieldNames = role.fieldNamesByFile;
const fileCount = role.fileIds.length;
return (
<Stack gap={4} style={{ borderBottom: '1px solid var(--mantine-color-default-border)', paddingBottom: 8 }}>
{/* Role header */}
<Group gap={8}>
<PersonIcon sx={{ fontSize: 16, color: role.isPrimaryPerson ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)' }} />
<Text size="sm" fw={600} style={{ flex: 1 }}>
{role.roleLabel}
</Text>
<Badge size="xs" variant="light">
{fileCount} file{fileCount !== 1 ? 's' : ''}
</Badge>
{role.isPrimaryPerson && (
<Badge size="xs" variant="light" color="blue">primary</Badge>
)}
</Group>
{/* Entity selector (grouped by type) */}
<Select
size="xs"
data={entitySelectData}
value={selectedEntityId || null}
onChange={(v) => v && onEntityChange(v)}
placeholder="Select entity..."
searchable
styles={{ input: { fontSize: '0.8125rem' } }}
/>
{/* Expand/collapse toggle */}
<UnstyledButton onClick={() => setExpanded(!expanded)}>
<Group gap={4}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
<Text size="xs" c="dimmed">
{expanded ? 'Hide fields' : `Show fields (${Object.values(roleFieldNames).flat().length})`}
</Text>
</Group>
</UnstyledButton>
{/* Expandable field list per file */}
<Collapse in={expanded}>
<Stack gap="xs" ml="sm">
{role.fileIds.map((fileId) => {
const fields = fieldsByFile[fileId] || [];
const roleFields = new Set(roleFieldNames[fileId] || []);
const skipped = skippedFieldsByFile[fileId] || new Set();
const cleanedLabels = cleanedLabelsByFile[fileId] || [];
const labelMap: Record<string, string> = {};
for (const cl of cleanedLabels) labelMap[cl.fieldName] = cl.label;
const visibleFields = fields.filter(
(f) => roleFields.has(f.name) && !skipped.has(f.name) && !f.readOnly
);
const fileOverride = fileRoleOverrides[fileId]?.[role.roleLabel];
return (
<Stack key={fileId} gap={2}>
{fileCount > 1 && (
<Group gap={4}>
<Text size="xs" fw={500} c="dimmed">{fileNames[fileId] || fileId}</Text>
{fileOverride && (
<Badge size="xs" variant="outline" color="orange">override: {fileOverride}</Badge>
)}
{fileCount > 1 && (
<Select
size="xs"
data={entitySelectData}
value={fileOverride || ''}
onChange={(v) => onFileOverride(fileId, v || '')}
placeholder="Default"
clearable
searchable
styles={{ input: { fontSize: '0.75rem', minHeight: 24 }, root: { maxWidth: 160 } }}
/>
)}
</Group>
)}
{visibleFields.map((field) => {
const displayLabel = cleanPdfText(labelMap[field.name] || field.label || field.name);
const fieldType = field.type as FormFieldType;
const icon = FIELD_TYPE_ICON[fieldType];
const color = FIELD_TYPE_COLOR[fieldType] || 'gray';
return (
<Group key={field.name} gap={6} pl={4}>
<Text c={color} size="xs" lh={1}>{icon}</Text>
<Text size="xs">{displayLabel}</Text>
{field.required && <Badge size="xs" variant="light" color="red">req</Badge>}
</Group>
);
})}
</Stack>
);
})}
</Stack>
</Collapse>
</Stack>
);
}
@@ -0,0 +1,60 @@
/**
* API client for the AI form fill engine (Python service on port 5001).
* Uses the Vite proxy at /engine-api to avoid CORS issues.
*/
import axios from 'axios';
import type {
AiFormFillRequest,
AiFormFillResponse,
DocumentExtractionRequest,
DocumentExtractionResponse,
FormAnalysisRequest,
FormAnalysisResponse,
FormFillBatchRequest,
FormFillBatchResponse,
} from './types';
const aiEngineClient = axios.create({
baseURL: '/engine-api',
headers: { 'Content-Type': 'application/json' },
});
export async function aiFormFill(
request: AiFormFillRequest
): Promise<AiFormFillResponse> {
const response = await aiEngineClient.post<AiFormFillResponse>(
'/api/v1/form/ai',
request
);
return response.data;
}
export async function extractFromDocuments(
request: DocumentExtractionRequest
): Promise<DocumentExtractionResponse> {
const response = await aiEngineClient.post<DocumentExtractionResponse>(
'/api/v1/form/ai/extract',
request
);
return response.data;
}
export async function analyseMultipleForms(
request: FormAnalysisRequest
): Promise<FormAnalysisResponse> {
const response = await aiEngineClient.post<FormAnalysisResponse>(
'/api/v1/form/ai/analyse',
request
);
return response.data;
}
export async function fillFormsBatch(
request: FormFillBatchRequest
): Promise<FormFillBatchResponse> {
const response = await aiEngineClient.post<FormFillBatchResponse>(
'/api/v1/form/ai/fill-batch',
request
);
return response.data;
}
@@ -0,0 +1,178 @@
/**
* Entity import/export utilities.
* JSON export/import for backup, CSV import for bulk entity creation.
*/
import type { Entity, EntityType, EntityStoreData } from './entityTypes';
import type { EntityStore } from './useEntityStore';
// --- JSON Export/Import ---
export function exportEntitiesToJson(entities: Entity[]): string {
return JSON.stringify({ entities, exportedAt: new Date().toISOString() }, null, 2);
}
export function downloadJson(content: string, filename: string): void {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 250);
}
export function importEntitiesFromJson(
jsonString: string,
store: EntityStore
): { imported: number; errors: string[] } {
const errors: string[] = [];
let imported = 0;
try {
const parsed = JSON.parse(jsonString);
const entities: any[] = parsed.entities || [];
for (const e of entities) {
if (!e.name || !e.type || !e.fields) {
errors.push(`Skipped invalid entity: ${JSON.stringify(e).slice(0, 50)}`);
continue;
}
const entity = store.createEntity(e.type as EntityType, e.name);
store.setManyFields(entity.id, e.fields);
imported++;
}
} catch (err) {
errors.push(`Failed to parse JSON: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
return { imported, errors };
}
// --- CSV Import ---
/**
* Parse CSV text into entities.
* Expected format: first row is headers, first column is entity name,
* remaining columns are field keys. Each row becomes one entity.
*
* Example:
* name,first_name,last_name,email,phone
* John Smith,John,Smith,john@example.com,555-1234
* Jane Doe,Jane,Doe,jane@example.com,555-5678
*/
export function parseCsvToEntities(
csvText: string,
entityType: EntityType = 'person'
): { entities: Array<{ name: string; type: EntityType; fields: Record<string, string> }>; errors: string[] } {
const errors: string[] = [];
const lines = csvText.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length < 2) {
errors.push('CSV must have at least a header row and one data row.');
return { entities: [], errors };
}
const headers = parseCSVLine(lines[0]);
const nameColIndex = headers.findIndex((h) => h.toLowerCase() === 'name');
if (nameColIndex === -1) {
errors.push('CSV must have a "name" column.');
return { entities: [], errors };
}
const entities: Array<{ name: string; type: EntityType; fields: Record<string, string> }> = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length !== headers.length) {
errors.push(`Row ${i + 1}: expected ${headers.length} columns, got ${values.length}. Skipped.`);
continue;
}
const name = values[nameColIndex];
if (!name) {
errors.push(`Row ${i + 1}: empty name. Skipped.`);
continue;
}
const fields: Record<string, string> = {};
for (let j = 0; j < headers.length; j++) {
if (j === nameColIndex) continue;
const key = headers[j].trim().toLowerCase().replace(/\s+/g, '_');
const value = values[j]?.trim() || '';
if (value) fields[key] = value;
}
entities.push({ name, type: entityType, fields });
}
return { entities, errors };
}
export function importCsvToStore(
csvText: string,
entityType: EntityType,
store: EntityStore
): { imported: number; errors: string[] } {
const { entities, errors } = parseCsvToEntities(csvText, entityType);
let imported = 0;
for (const e of entities) {
const existing = store.entities.find((ex) => ex.name === e.name && ex.type === e.type);
if (existing) {
store.setManyFields(existing.id, e.fields);
} else {
const entity = store.createEntity(e.type, e.name);
store.setManyFields(entity.id, e.fields);
}
imported++;
}
return { imported, errors };
}
/** Simple CSV line parser that handles quoted fields */
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
// --- Same-Template-Different-Data Batch ---
/**
* Generate N copies of fill requests — one per entity — for the same form template.
* Used when filling the same form for multiple people (e.g. pension enrollment for 5 employees).
*/
export function generateTemplateBatch(
templateFields: Array<{ name: string; label: string; type: string }>,
entityIds: string[],
store: EntityStore
): Array<{ entityId: string; entityName: string; knowledge: Record<string, string> }> {
return entityIds.map((id) => {
const entity = store.getEntity(id);
return {
entityId: id,
entityName: entity?.name || id,
knowledge: entity?.fields || {},
};
});
}
@@ -0,0 +1,113 @@
/**
* Typed entity system for AI Form Fill.
* Replaces flat profile store with typed entities (person, company, site, etc.)
*/
export type EntityType =
| 'person'
| 'company'
| 'site'
| 'property'
| 'certification'
| 'custom';
export interface Entity {
id: string;
type: EntityType;
name: string;
fields: Record<string, string>;
createdAt: number;
updatedAt: number;
}
export interface EntityStoreData {
version: 2;
entities: Record<string, Entity>;
defaultEntityId: string | null;
}
/** Schema hints per entity type — suggestive field names for autocomplete */
export const ENTITY_TYPE_HINTS: Record<EntityType, string[]> = {
person: [
'first_name', 'last_name', 'full_name', 'date_of_birth', 'email', 'phone',
'address_line_1', 'address_line_2', 'city', 'state', 'postcode', 'country',
'ni_number', 'nationality', 'gender', 'job_title',
],
company: [
'company_name', 'registration_number', 'tax_id', 'vat_number',
'address_line_1', 'address_line_2', 'city', 'postcode', 'country',
'phone', 'email', 'bank_sort_code', 'bank_account_number',
],
site: [
'site_name', 'address_line_1', 'address_line_2', 'city', 'postcode',
'site_reference', 'description', 'client_name', 'start_date', 'end_date',
],
property: [
'address_line_1', 'address_line_2', 'city', 'postcode',
'title_number', 'tenure', 'price', 'description',
],
certification: [
'cert_number', 'cert_name', 'issuer', 'holder_name',
'issue_date', 'expiry_date',
],
custom: [],
};
export const ENTITY_TYPE_LABELS: Record<EntityType, string> = {
person: 'Person',
company: 'Company',
site: 'Site / Job',
property: 'Property',
certification: 'Certification',
custom: 'Custom',
};
export const ENTITY_TYPE_ICONS: Record<EntityType, string> = {
person: 'person',
company: 'business',
site: 'location_on',
property: 'home',
certification: 'verified',
custom: 'category',
};
export const ALL_ENTITY_TYPES: EntityType[] = [
'person', 'company', 'site', 'property', 'certification', 'custom',
];
/** Merge multiple entities into a single flat dict for the AI engine. */
export interface MergeResult {
knowledge: Record<string, string>;
provenance: Record<string, { entityId: string; entityName: string }>;
}
export function mergeEntitiesForFill(
assignments: Array<{ roleLabel: string; entity: Entity }>
): MergeResult {
const knowledge: Record<string, string> = {};
const provenance: Record<string, { entityId: string; entityName: string }> = {};
const multiEntity = assignments.length > 1;
for (const { roleLabel, entity } of assignments) {
const prefix = roleLabel.toLowerCase().replace(/\s+/g, '_');
for (const [key, value] of Object.entries(entity.fields)) {
if (key.startsWith('_')) continue;
// Unprefixed: first writer wins
if (!(key in knowledge)) {
knowledge[key] = value;
provenance[key] = { entityId: entity.id, entityName: entity.name };
}
// Always add prefixed version for multi-entity fills
if (multiEntity) {
const prefixedKey = `${prefix}_${key}`;
knowledge[prefixedKey] = value;
provenance[prefixedKey] = { entityId: entity.id, entityName: entity.name };
}
}
}
return { knowledge, provenance };
}
@@ -0,0 +1,29 @@
/**
* Shared PDF text extraction utility.
* Extracts text content per page from a PDF file using PDF.js.
*/
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
export async function extractPageTexts(file: File | Blob): Promise<Record<number, string>> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
const pageTexts: Record<number, string> = {};
try {
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
pageTexts[i - 1] = textContent.items
.map((item: any) => item.str)
.join(' ');
}
} finally {
pdfWorkerManager.destroyDocument(pdf);
}
return pageTexts;
}
/** Extract all text from a PDF as a single string. */
export async function extractFullText(file: File | Blob): Promise<string> {
const pageTexts = await extractPageTexts(file);
return Object.values(pageTexts).join('\n');
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Types for the AI Form Fill tool.
* Mirror the Python engine's form_fill contracts.
*/
import type { FormField } from '@app/tools/formFill/types';
export type { FormField };
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
export interface AiFormFillRequest {
userMessage: string;
conversationHistory: ConversationMessage[];
formFields: FormField[];
knowledge: Record<string, string>;
extractedDocumentText?: string;
roleOverride?: string;
}
export interface FieldMapping {
fieldName: string;
knowledgeKey: string;
value: string;
}
export interface CleanedLabel {
fieldName: string;
label: string;
}
export interface KnowledgeEntry {
key: string;
value: string;
source: string;
}
export interface DetectedRole {
roleLabel: string;
fieldNames: string[];
isPrimaryPerson: boolean;
}
export interface RoleDetectionResult {
detectedRoles: DetectedRole[];
primaryRoleLabel: string | null;
primaryConfidence: number;
confidenceReasoning: string;
}
export interface FillResultResponse {
outcome: 'fill_result';
filledFields: FieldMapping[];
cleanedLabels: CleanedLabel[];
skippedFieldNames: string[];
roleDetection: RoleDetectionResult | null;
message: string;
}
export interface RoleConfirmationResponse {
outcome: 'role_confirmation_needed';
roleDetection: RoleDetectionResult;
suggestedPrimary: string;
question: string;
provisionalFills: FieldMapping[];
cleanedLabels: CleanedLabel[];
skippedFieldNames: string[];
}
export interface KnowledgeUpdateResponse {
outcome: 'knowledge_update';
proposedEntries: KnowledgeEntry[];
message: string;
}
export interface FormFillClarificationResponse {
outcome: 'form_fill_clarification';
question: string;
reason: string;
}
export type AiFormFillResponse =
| FillResultResponse
| RoleConfirmationResponse
| KnowledgeUpdateResponse
| FormFillClarificationResponse;
export interface DocumentText {
fileName: string;
text: string;
}
export interface DocumentExtractionRequest {
documents: DocumentText[];
existingProfileNames: string[];
}
export interface ProposedProfile {
suggestedName: string;
entries: KnowledgeEntry[];
sourceDocuments: string[];
}
export interface MultiProfileExtractionResponse {
outcome: 'multi_profile_extraction';
proposedProfiles: ProposedProfile[];
message: string;
}
export type DocumentExtractionResponse =
| KnowledgeUpdateResponse
| MultiProfileExtractionResponse;
// --- Form Analysis (multi-file) ---
export interface FileFieldSet {
fileId: string;
fileName: string;
formFields: FormField[];
}
export interface FormAnalysisRequest {
files: FileFieldSet[];
}
export interface AnalysedFileResult {
fileId: string;
fileName: string;
detectedRoles: DetectedRole[];
cleanedLabels: CleanedLabel[];
skippedFieldNames: string[];
}
export interface CrossFileRole {
roleLabel: string;
fileIds: string[];
fieldNamesByFile: Record<string, string[]>;
isPrimaryPerson: boolean;
}
export interface FormAnalysisResponse {
perFile: AnalysedFileResult[];
crossFileRoles: CrossFileRole[];
message: string;
}
// --- Batch Fill ---
export interface FileFillRequest {
fileId: string;
formFields: FormField[];
roleLabel: string;
}
export interface FormFillBatchRequest {
files: FileFillRequest[];
knowledge: Record<string, string>;
}
export interface FileFillResult {
fileId: string;
filledFields: FieldMapping[];
}
export interface FormFillBatchResponse {
outcome: 'batch_fill_result';
perFile: FileFillResult[];
message: string;
}
export type AiFormFillPhase =
| 'idle'
| 'fetching_fields'
| 'analysing'
| 'analysis_review'
| 'filling'
| 'role_confirm'
| 'results'
| 'applying';
@@ -0,0 +1,234 @@
/**
* AI Form Fill flow hook.
* Thin layer on top of FormFillContext — sends fields to AI engine,
* writes fills back via setValue(). Handles role detection and confirmation.
*/
import { useState, useCallback, useMemo } from 'react';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { aiFormFill } from './aiFormFillApi';
import { extractPageTexts } from './pdfTextExtraction';
import type {
AiFormFillResponse,
CleanedLabel,
FieldMapping,
FormField,
RoleConfirmationResponse,
RoleDetectionResult,
} from './types';
type SingleFilePhase = 'setup' | 'filling' | 'role_confirm' | 'results';
import type { KnowledgeStore } from './useKnowledgeStore';
export interface AiFormFillFlowState {
phase: SingleFilePhase;
filledFields: FieldMapping[];
cleanedLabels: CleanedLabel[];
skippedFieldNames: string[];
roleDetection: RoleDetectionResult | null;
pendingConfirmation: RoleConfirmationResponse | null;
aiMessage: string | null;
loading: boolean;
error: string | null;
}
const INITIAL_STATE: AiFormFillFlowState = {
phase: 'setup',
filledFields: [],
cleanedLabels: [],
skippedFieldNames: [],
roleDetection: null,
pendingConfirmation: null,
aiMessage: null,
loading: false,
error: null,
};
function buildFieldsForAi(
fields: FormField[],
pageTexts: Record<number, string>
): FormField[] {
return fields.map((f) => {
const pageIndex = f.widgets?.[0]?.pageIndex;
const nearbyText = pageIndex != null ? pageTexts[pageIndex] : undefined;
return {
name: f.name,
label: f.label,
type: f.type,
value: f.value,
options: f.options,
displayOptions: f.displayOptions,
required: f.required,
readOnly: f.readOnly,
multiSelect: f.multiSelect,
multiline: f.multiline,
tooltip: f.tooltip,
nearbyPageText: nearbyText,
} as unknown as FormField;
});
}
export function useAiFormFillFlow(knowledge: KnowledgeStore) {
const [state, setState] = useState<AiFormFillFlowState>(INITIAL_STATE);
const formFill = useFormFill();
const labelMap = useMemo(() => {
const map: Record<string, string> = {};
for (const cl of state.cleanedLabels) {
map[cl.fieldName] = cl.label;
}
return map;
}, [state.cleanedLabels]);
const applyFills = useCallback(
(fills: FieldMapping[]) => {
for (const fill of fills) {
formFill.setValue(fill.fieldName, fill.value);
}
},
[formFill]
);
const startAutoFill = useCallback(
async (file: File | Blob, roleOverride?: string) => {
setState((s) => ({ ...s, phase: 'filling', loading: true, error: null, pendingConfirmation: null }));
try {
const fields = formFill.state.fields;
if (fields.length === 0) {
setState((s) => ({
...s,
phase: 'setup',
loading: false,
error: 'No fillable form fields found.',
}));
return;
}
let pageTexts: Record<number, string> = {};
try {
pageTexts = await extractPageTexts(file);
} catch {
// Non-critical
}
const fieldsForAi = buildFieldsForAi(fields, pageTexts);
const response: AiFormFillResponse = await aiFormFill({
userMessage: 'Fill this form with my known information.',
conversationHistory: [],
formFields: fieldsForAi,
knowledge: knowledge.entries,
roleOverride: roleOverride || undefined,
});
console.log('[AI Form Fill] Response:', JSON.stringify(response, null, 2));
if (response.outcome === 'fill_result') {
applyFills(response.filledFields);
setState((s) => ({
...s,
phase: 'results',
loading: false,
filledFields: response.filledFields,
cleanedLabels: response.cleanedLabels || [],
skippedFieldNames: response.skippedFieldNames || [],
roleDetection: response.roleDetection || null,
aiMessage: response.message,
}));
} else if (response.outcome === 'role_confirmation_needed') {
setState((s) => ({
...s,
phase: 'role_confirm',
loading: false,
pendingConfirmation: response,
cleanedLabels: response.cleanedLabels || [],
skippedFieldNames: response.skippedFieldNames || [],
roleDetection: response.roleDetection,
aiMessage: response.question,
}));
} else if (response.outcome === 'form_fill_clarification') {
setState((s) => ({
...s,
phase: 'setup',
loading: false,
error: response.question,
}));
} else {
setState((s) => ({
...s,
phase: 'setup',
loading: false,
error: 'Unexpected response from AI engine.',
}));
}
} catch (err) {
setState((s) => ({
...s,
phase: 'setup',
loading: false,
error: err instanceof Error ? err.message : 'AI form filling failed.',
}));
}
},
[knowledge.entries, formFill, applyFills]
);
/** User confirms the AI's suggested role — apply provisional fills, no second call */
const confirmRole = useCallback(
(remember: boolean) => {
const pending = state.pendingConfirmation;
if (!pending) return;
applyFills(pending.provisionalFills);
if (remember && pending.suggestedPrimary) {
const current = knowledge.entries['_role_preference'] || '';
const keywords = current ? current.split(',').map((k) => k.trim().toLowerCase()) : [];
const newKeyword = pending.suggestedPrimary.toLowerCase();
if (!keywords.includes(newKeyword)) {
keywords.push(newKeyword);
knowledge.set('_role_preference', keywords.join(','));
}
}
setState((s) => ({
...s,
phase: 'results',
filledFields: pending.provisionalFills,
pendingConfirmation: null,
aiMessage: `Filled as "${pending.suggestedPrimary}".`,
}));
},
[state.pendingConfirmation, applyFills, knowledge]
);
/** User picks a different role — re-run with override */
const selectRole = useCallback(
async (roleLabel: string, file: File | Blob, remember: boolean) => {
if (remember) {
const current = knowledge.entries['_role_preference'] || '';
const keywords = current ? current.split(',').map((k) => k.trim().toLowerCase()) : [];
const newKeyword = roleLabel.toLowerCase();
if (!keywords.includes(newKeyword)) {
keywords.push(newKeyword);
knowledge.set('_role_preference', keywords.join(','));
}
}
await startAutoFill(file, roleLabel);
},
[knowledge, startAutoFill]
);
const reset = useCallback(() => {
setState(INITIAL_STATE);
}, []);
return {
...state,
labelMap,
formFill,
startAutoFill,
confirmRole,
selectRole,
reset,
};
}
@@ -0,0 +1,243 @@
/**
* Hook for batch form filling using the typed entity system.
* Merges entities for multi-entity filling. Includes fill preview phase.
*/
import { useState, useCallback } from 'react';
import { fillFormFields } from '@app/tools/formFill/formApi';
import { fillFormsBatch } from './aiFormFillApi';
import { mergeEntitiesForFill, type MergeResult } from './entityTypes';
import { resolveDynamicValues } from './workflowTemplates';
import type {
FileFillResult,
FormFillBatchResponse,
FormField,
FieldMapping,
} from './types';
import type { FormField as FullFormField } from '@app/tools/formFill/types';
import type { FormAnalysisState } from './useFormAnalysis';
import type { KnowledgeStore } from './useKnowledgeStore';
export type BatchFillPhase = 'idle' | 'filling' | 'preview' | 'applying' | 'done' | 'error';
interface StirlingFile extends File {
readonly fileId: string;
}
export interface PreviewField {
fileId: string;
fieldName: string;
value: string;
entityName: string;
accepted: boolean;
}
export interface BatchFillState {
phase: BatchFillPhase;
results: FileFillResult[];
previewFields: PreviewField[];
message: string | null;
error: string | null;
}
const INITIAL_STATE: BatchFillState = {
phase: 'idle',
results: [],
previewFields: [],
message: null,
error: null,
};
export function useBatchFormFillFlow(
analysis: FormAnalysisState,
knowledge: KnowledgeStore
) {
const [state, setState] = useState<BatchFillState>(INITIAL_STATE);
const entityStore = knowledge.entityStore;
const fillAllFiles = useCallback(
async (files: StirlingFile[]) => {
if (!analysis.analysis) return;
setState((s) => ({ ...s, phase: 'filling', error: null }));
try {
// Build per-file fill requests using entity merge
// Group files by their set of role→entity assignments (to batch efficiently)
const fileRequests: Array<{
fileId: string;
fields: FullFormField[];
roleLabel: string;
mergeResult: MergeResult;
}> = [];
for (const role of analysis.analysis.crossFileRoles) {
for (const fileId of role.fileIds) {
const entityId =
analysis.fileRoleOverrides[fileId]?.[role.roleLabel] ||
analysis.roleProfileMap[role.roleLabel];
if (!entityId) continue;
const entity = entityStore.getEntity(entityId);
if (!entity) continue;
const roleFieldNames = new Set(role.fieldNamesByFile[fileId] || []);
const fileFields = (analysis.fieldsByFile[fileId] || []).filter(
(f) => roleFieldNames.has(f.name) && !f.readOnly
);
if (fileFields.length === 0) continue;
// Collect ALL entity assignments for this file (across all roles)
const allAssignments = analysis.analysis.crossFileRoles
.filter((r) => r.fileIds.includes(fileId))
.map((r) => {
const eid =
analysis.fileRoleOverrides[fileId]?.[r.roleLabel] ||
analysis.roleProfileMap[r.roleLabel];
const ent = eid ? entityStore.getEntity(eid) : undefined;
return ent ? { roleLabel: r.roleLabel, entity: ent } : null;
})
.filter(Boolean) as Array<{ roleLabel: string; entity: any }>;
const mergeResult = mergeEntitiesForFill(allAssignments);
fileRequests.push({
fileId,
fields: fileFields,
roleLabel: role.roleLabel,
mergeResult,
});
}
}
// Deduplicate by fileId (a file may appear in multiple roles)
const seen = new Set<string>();
const dedupedRequests = fileRequests.filter((fr) => {
if (seen.has(fr.fileId)) return false;
seen.add(fr.fileId);
return true;
});
// Build batch request — one entry per file, with merged knowledge
const rawKnowledge = dedupedRequests[0]?.mergeResult.knowledge || {};
const batchRequest = {
files: dedupedRequests.map((fr) => ({
fileId: fr.fileId,
formFields: fr.fields.map((f) => ({
name: f.name,
label: f.label,
type: f.type,
value: f.value,
options: f.options,
displayOptions: f.displayOptions,
required: f.required,
readOnly: f.readOnly,
multiSelect: f.multiSelect,
multiline: f.multiline,
tooltip: f.tooltip,
})) as unknown as FormField[],
roleLabel: fr.roleLabel,
})),
knowledge: resolveDynamicValues(rawKnowledge),
};
const response: FormFillBatchResponse = await fillFormsBatch(batchRequest);
// Build preview data with provenance
const previewFields: PreviewField[] = [];
for (const fileResult of response.perFile) {
const req = dedupedRequests.find((r) => r.fileId === fileResult.fileId);
for (const fill of fileResult.filledFields) {
const prov = req?.mergeResult.provenance[fill.knowledgeKey];
previewFields.push({
fileId: fileResult.fileId,
fieldName: fill.fieldName,
value: fill.value,
entityName: prov?.entityName || 'Unknown',
accepted: true,
});
}
}
setState({
phase: 'preview',
results: response.perFile,
previewFields,
message: response.message,
error: null,
});
} catch (err) {
setState((s) => ({
...s,
phase: 'error',
error: err instanceof Error ? err.message : 'Batch fill failed.',
}));
}
},
[analysis, entityStore]
);
const togglePreviewField = useCallback((index: number) => {
setState((s) => ({
...s,
previewFields: s.previewFields.map((f, i) =>
i === index ? { ...f, accepted: !f.accepted } : f
),
}));
}, []);
const editPreviewField = useCallback((index: number, newValue: string) => {
setState((s) => ({
...s,
previewFields: s.previewFields.map((f, i) =>
i === index ? { ...f, value: newValue } : f
),
}));
}, []);
/** Apply previewed fills to the actual PDFs */
const applyPreview = useCallback(
async (files: StirlingFile[]) => {
setState((s) => ({ ...s, phase: 'applying' }));
try {
// Group accepted preview fields by file
const valuesByFile: Record<string, Record<string, string>> = {};
for (const field of state.previewFields) {
if (!field.accepted) continue;
if (!valuesByFile[field.fileId]) valuesByFile[field.fileId] = {};
valuesByFile[field.fileId][field.fieldName] = field.value;
}
for (const [fileId, values] of Object.entries(valuesByFile)) {
const file = files.find((f) => f.fileId === fileId);
if (!file || Object.keys(values).length === 0) continue;
const filledBlob = await fillFormFields(file, values, false);
window.dispatchEvent(
new CustomEvent('formfill:apply', { detail: { blob: filledBlob, fileId } })
);
}
const totalFilled = state.previewFields.filter((f) => f.accepted).length;
setState({
phase: 'done',
results: state.results,
previewFields: state.previewFields,
message: `Applied ${totalFilled} fields across ${Object.keys(valuesByFile).length} files.`,
error: null,
});
} catch (err) {
setState((s) => ({
...s,
phase: 'error',
error: err instanceof Error ? err.message : 'Failed to apply fills.',
}));
}
},
[state.previewFields, state.results]
);
const reset = useCallback(() => {
setState(INITIAL_STATE);
}, []);
return { ...state, fillAllFiles, togglePreviewField, editPreviewField, applyPreview, reset };
}
@@ -0,0 +1,186 @@
/**
* State machine hook for document import flow.
* Handles: file drop → text extraction → AI analysis → review → commit to profile store.
*/
import { useState, useCallback } from 'react';
import { extractFullText } from './pdfTextExtraction';
import { extractFromDocuments } from './aiFormFillApi';
import type { KnowledgeEntry } from './types';
import type { KnowledgeStore } from './useKnowledgeStore';
export type ImportState = 'idle' | 'extracting_text' | 'calling_ai' | 'reviewing' | 'error';
export interface ReviewableEntry {
key: string;
value: string;
source: string;
accepted: boolean;
}
export interface ReviewableProfile {
name: string;
accepted: boolean;
entries: ReviewableEntry[];
sourceDocuments: string[];
}
export interface DocumentImport {
state: ImportState;
extractionProgress: { completed: number; total: number };
proposedProfiles: ReviewableProfile[];
error: string | null;
startImport: (files: File[], existingEntityNames?: string[]) => void;
toggleEntry: (profileIndex: number, entryIndex: number) => void;
editEntryValue: (profileIndex: number, entryIndex: number, newValue: string) => void;
setProfileName: (profileIndex: number, name: string) => void;
toggleProfile: (profileIndex: number) => void;
acceptAndCommit: (knowledge: KnowledgeStore) => void;
reset: () => void;
}
function entriesToReviewable(entries: KnowledgeEntry[]): ReviewableEntry[] {
return entries.map((e) => ({
key: e.key,
value: e.value,
source: e.source,
accepted: true,
}));
}
export function useDocumentImport(): DocumentImport {
const [state, setState] = useState<ImportState>('idle');
const [progress, setProgress] = useState({ completed: 0, total: 0 });
const [profiles, setProfiles] = useState<ReviewableProfile[]>([]);
const [error, setError] = useState<string | null>(null);
const startImport = useCallback(async (files: File[], existingEntityNames: string[] = []) => {
setState('extracting_text');
setError(null);
setProgress({ completed: 0, total: files.length });
try {
// Extract text from each file
const documents: { fileName: string; text: string }[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const text = await extractFullText(file);
documents.push({ fileName: file.name, text });
setProgress({ completed: i + 1, total: files.length });
}
// Call AI engine
setState('calling_ai');
const response = await extractFromDocuments({
documents,
existingProfileNames: existingEntityNames,
});
console.log('[Document Import] Response:', JSON.stringify(response, null, 2));
if (response.outcome === 'multi_profile_extraction') {
setProfiles(
response.proposedProfiles.map((p) => ({
name: p.suggestedName,
accepted: true,
entries: entriesToReviewable(p.entries),
sourceDocuments: p.sourceDocuments,
}))
);
} else if (response.outcome === 'knowledge_update') {
// Single profile — use first file name as hint
const name = documents.length === 1 ? documents[0].fileName.replace(/\.[^.]+$/, '') : 'Imported';
setProfiles([
{
name,
accepted: true,
entries: entriesToReviewable(response.proposedEntries),
sourceDocuments: documents.map((d) => d.fileName),
},
]);
}
setState('reviewing');
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed.');
setState('error');
}
}, []);
const toggleEntry = useCallback((profileIndex: number, entryIndex: number) => {
setProfiles((prev) =>
prev.map((p, pi) =>
pi === profileIndex
? { ...p, entries: p.entries.map((e, ei) => (ei === entryIndex ? { ...e, accepted: !e.accepted } : e)) }
: p
)
);
}, []);
const editEntryValue = useCallback((profileIndex: number, entryIndex: number, newValue: string) => {
setProfiles((prev) =>
prev.map((p, pi) =>
pi === profileIndex
? { ...p, entries: p.entries.map((e, ei) => (ei === entryIndex ? { ...e, value: newValue } : e)) }
: p
)
);
}, []);
const setProfileName = useCallback((profileIndex: number, name: string) => {
setProfiles((prev) => prev.map((p, pi) => (pi === profileIndex ? { ...p, name } : p)));
}, []);
const toggleProfile = useCallback((profileIndex: number) => {
setProfiles((prev) => prev.map((p, pi) => (pi === profileIndex ? { ...p, accepted: !p.accepted } : p)));
}, []);
const acceptAndCommit = useCallback(
(knowledge: KnowledgeStore) => {
const store = knowledge.entityStore;
for (const profile of profiles) {
if (!profile.accepted) continue;
const acceptedEntries: Record<string, string> = {};
for (const entry of profile.entries) {
if (entry.accepted) {
acceptedEntries[entry.key] = entry.value;
}
}
if (Object.keys(acceptedEntries).length === 0) continue;
// Find existing entity by name, or create new one as 'person' type
const existing = store.entities.find((e) => e.name === profile.name);
if (existing) {
store.setManyFields(existing.id, acceptedEntries);
} else {
const entity = store.createEntity('person', profile.name);
store.setManyFields(entity.id, acceptedEntries);
}
}
setProfiles([]);
setState('idle');
},
[profiles]
);
const reset = useCallback(() => {
setState('idle');
setProfiles([]);
setError(null);
setProgress({ completed: 0, total: 0 });
}, []);
return {
state,
extractionProgress: progress,
proposedProfiles: profiles,
error,
startImport,
toggleEntry,
editEntryValue,
setProfileName,
toggleProfile,
acceptAndCommit,
reset,
};
}
@@ -0,0 +1,308 @@
/**
* Entity store — localStorage-backed typed entity management.
* Replaces the flat profile store. Migrates v1 profiles to v2 entities on first load.
*/
import { useState, useCallback, useMemo } from 'react';
import type { Entity, EntityType, EntityStoreData } from './entityTypes';
const STORAGE_KEY = 'stirling-pdf-ai-profiles';
// --- v1 types for migration ---
interface V1StoredData {
profiles: Record<string, Record<string, string>>;
activeProfile: string;
}
function isV1Data(data: any): data is V1StoredData {
return data && !data.version && data.profiles;
}
function migrateV1toV2(v1: V1StoredData): EntityStoreData {
const entities: Record<string, Entity> = {};
let defaultEntityId: string | null = null;
for (const [profileName, entries] of Object.entries(v1.profiles)) {
const id = crypto.randomUUID();
const fields: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
if (!k.startsWith('_')) fields[k] = v;
}
entities[id] = {
id,
type: 'person',
name: profileName,
fields,
createdAt: Date.now(),
updatedAt: Date.now(),
};
if (profileName === v1.activeProfile) {
defaultEntityId = id;
}
}
if (!defaultEntityId && Object.keys(entities).length > 0) {
defaultEntityId = Object.keys(entities)[0];
}
return { version: 2, entities, defaultEntityId };
}
function loadFromStorage(): EntityStoreData {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (isV1Data(parsed)) {
const migrated = migrateV1toV2(parsed);
saveToStorage(migrated);
return migrated;
}
if (parsed.version === 2) return parsed;
}
} catch { /* ignore */ }
// Default: empty store with one person entity
const defaultId = crypto.randomUUID();
return {
version: 2,
entities: {
[defaultId]: {
id: defaultId,
type: 'person',
name: 'Me',
fields: {},
createdAt: Date.now(),
updatedAt: Date.now(),
},
},
defaultEntityId: defaultId,
};
}
function saveToStorage(data: EntityStoreData): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch { /* QuotaExceededError */ }
}
export interface EntityStore {
/** All entities */
entities: Entity[];
/** Entity lookup by ID */
getEntity: (id: string) => Entity | undefined;
/** Entities grouped by type */
entitiesByType: Record<EntityType, Entity[]>;
/** Default entity ID */
defaultEntityId: string | null;
/** CRUD */
createEntity: (type: EntityType, name: string) => Entity;
updateEntity: (id: string, updates: Partial<Pick<Entity, 'name' | 'type'>>) => void;
deleteEntity: (id: string) => void;
duplicateEntity: (id: string, newName: string) => Entity;
setDefaultEntity: (id: string) => void;
/** Field operations on an entity */
setField: (entityId: string, key: string, value: string) => void;
setManyFields: (entityId: string, fields: Record<string, string>) => void;
removeField: (entityId: string, key: string) => void;
clearFields: (entityId: string) => void;
/** For Select components — grouped data */
selectData: Array<{ group: string; items: Array<{ value: string; label: string }> }>;
}
export function useEntityStore(): EntityStore {
const [data, setData] = useState<EntityStoreData>(loadFromStorage);
const update = useCallback((fn: (prev: EntityStoreData) => EntityStoreData) => {
setData((prev) => {
const next = fn(prev);
saveToStorage(next);
return next;
});
}, []);
const entities = useMemo(() => Object.values(data.entities), [data.entities]);
const getEntity = useCallback(
(id: string) => data.entities[id],
[data.entities]
);
const entitiesByType = useMemo(() => {
const grouped: Record<EntityType, Entity[]> = {
person: [], company: [], site: [], property: [], certification: [], custom: [],
};
for (const e of entities) {
grouped[e.type].push(e);
}
return grouped;
}, [entities]);
const createEntity = useCallback((type: EntityType, name: string): Entity => {
const entity: Entity = {
id: crypto.randomUUID(),
type,
name,
fields: {},
createdAt: Date.now(),
updatedAt: Date.now(),
};
update((prev) => ({
...prev,
entities: { ...prev.entities, [entity.id]: entity },
}));
return entity;
}, [update]);
const updateEntity = useCallback((id: string, updates: Partial<Pick<Entity, 'name' | 'type'>>) => {
update((prev) => {
const entity = prev.entities[id];
if (!entity) return prev;
return {
...prev,
entities: {
...prev.entities,
[id]: { ...entity, ...updates, updatedAt: Date.now() },
},
};
});
}, [update]);
const deleteEntity = useCallback((id: string) => {
update((prev) => {
const { [id]: _, ...rest } = prev.entities;
const remaining = Object.keys(rest);
return {
...prev,
entities: rest,
defaultEntityId: prev.defaultEntityId === id
? (remaining[0] || null)
: prev.defaultEntityId,
};
});
}, [update]);
const duplicateEntity = useCallback((id: string, newName: string): Entity => {
const source = data.entities[id];
const entity: Entity = {
id: crypto.randomUUID(),
type: source?.type || 'custom',
name: newName,
fields: { ...(source?.fields || {}) },
createdAt: Date.now(),
updatedAt: Date.now(),
};
update((prev) => ({
...prev,
entities: { ...prev.entities, [entity.id]: entity },
}));
return entity;
}, [data.entities, update]);
const setDefaultEntity = useCallback((id: string) => {
update((prev) => ({ ...prev, defaultEntityId: id }));
}, [update]);
const setField = useCallback((entityId: string, key: string, value: string) => {
update((prev) => {
const entity = prev.entities[entityId];
if (!entity) return prev;
return {
...prev,
entities: {
...prev.entities,
[entityId]: {
...entity,
fields: { ...entity.fields, [key]: value },
updatedAt: Date.now(),
},
},
};
});
}, [update]);
const setManyFields = useCallback((entityId: string, fields: Record<string, string>) => {
update((prev) => {
const entity = prev.entities[entityId];
if (!entity) return prev;
return {
...prev,
entities: {
...prev.entities,
[entityId]: {
...entity,
fields: { ...entity.fields, ...fields },
updatedAt: Date.now(),
},
},
};
});
}, [update]);
const removeField = useCallback((entityId: string, key: string) => {
update((prev) => {
const entity = prev.entities[entityId];
if (!entity) return prev;
const fields = { ...entity.fields };
delete fields[key];
return {
...prev,
entities: {
...prev.entities,
[entityId]: { ...entity, fields, updatedAt: Date.now() },
},
};
});
}, [update]);
const clearFields = useCallback((entityId: string) => {
update((prev) => {
const entity = prev.entities[entityId];
if (!entity) return prev;
return {
...prev,
entities: {
...prev.entities,
[entityId]: { ...entity, fields: {}, updatedAt: Date.now() },
},
};
});
}, [update]);
const selectData = useMemo(() => {
const groups: Array<{ group: string; items: Array<{ value: string; label: string }> }> = [];
const typeLabels: Record<EntityType, string> = {
person: 'People', company: 'Companies', site: 'Sites / Jobs',
property: 'Properties', certification: 'Certifications', custom: 'Custom',
};
for (const [type, label] of Object.entries(typeLabels)) {
const items = entitiesByType[type as EntityType];
if (items.length > 0) {
groups.push({
group: label,
items: items.map((e) => ({ value: e.id, label: e.name })),
});
}
}
return groups;
}, [entitiesByType]);
return {
entities,
getEntity,
entitiesByType,
defaultEntityId: data.defaultEntityId,
createEntity,
updateEntity,
deleteEntity,
duplicateEntity,
setDefaultEntity,
setField,
setManyFields,
removeField,
clearFields,
selectData,
};
}
@@ -0,0 +1,189 @@
/**
* Hook for the Form Analysis phase.
* Fetches fields for all files, extracts page text, sends to AI analyser,
* stores analysis result with role-grouped data.
*/
import { useState, useCallback } from 'react';
import { fetchFormFieldsWithCoordinates } from '@app/tools/formFill/formApi';
import { extractPageTexts } from './pdfTextExtraction';
import { analyseMultipleForms } from './aiFormFillApi';
import type {
FormAnalysisResponse,
FormField,
CrossFileRole,
} from './types';
import type { FormField as FullFormField } from '@app/tools/formFill/types';
import type { KnowledgeStore } from './useKnowledgeStore';
export type AnalysisPhase = 'idle' | 'fetching_fields' | 'analysing' | 'done' | 'error';
export interface FormAnalysisState {
phase: AnalysisPhase;
/** Raw fields fetched from Java backend, keyed by fileId */
fieldsByFile: Record<string, FullFormField[]>;
/** AI analysis result */
analysis: FormAnalysisResponse | null;
/** Role → profile name assignment (user picks these) */
roleProfileMap: Record<string, string>;
/** Per-file role overrides: fileId → roleLabel → profileName */
fileRoleOverrides: Record<string, Record<string, string>>;
error: string | null;
}
const INITIAL_STATE: FormAnalysisState = {
phase: 'idle',
fieldsByFile: {},
analysis: null,
roleProfileMap: {},
fileRoleOverrides: {},
error: null,
};
interface StirlingFile extends File {
readonly fileId: string;
}
function buildFieldForAi(field: FullFormField, pageTexts: Record<number, string>): FormField {
const pageIndex = field.widgets?.[0]?.pageIndex;
const nearbyText = pageIndex != null ? pageTexts[pageIndex] : undefined;
return {
name: field.name,
label: field.label,
type: field.type,
value: field.value,
options: field.options,
displayOptions: field.displayOptions,
required: field.required,
readOnly: field.readOnly,
multiSelect: field.multiSelect,
multiline: field.multiline,
tooltip: field.tooltip,
nearbyPageText: nearbyText,
} as unknown as FormField;
}
export function useFormAnalysis(knowledge: KnowledgeStore) {
const [state, setState] = useState<FormAnalysisState>(INITIAL_STATE);
const analyseAllFiles = useCallback(async (files: StirlingFile[]) => {
setState((s) => ({ ...s, phase: 'fetching_fields', error: null }));
try {
// Step 1: Fetch fields for all files from Java backend (parallel)
const fieldsByFile: Record<string, FullFormField[]> = {};
await Promise.all(
files.map(async (sf) => {
const fields = await fetchFormFieldsWithCoordinates(sf);
fieldsByFile[sf.fileId] = fields;
})
);
// Step 2: Extract page texts per file (parallel)
const pageTextsByFile: Record<string, Record<number, string>> = {};
await Promise.all(
files.map(async (sf) => {
try {
pageTextsByFile[sf.fileId] = await extractPageTexts(sf);
} catch {
// Non-critical
}
})
);
setState((s) => ({ ...s, phase: 'analysing', fieldsByFile }));
// Step 3: Build analysis request
const request = {
files: files.map((sf) => ({
fileId: sf.fileId,
fileName: sf.name,
formFields: (fieldsByFile[sf.fileId] || []).map((f) =>
buildFieldForAi(f, pageTextsByFile[sf.fileId] || {})
),
})),
};
// Step 4: Call analyser (one call for all files)
const analysis = await analyseMultipleForms(request);
console.log('[Form Analysis] Response:', JSON.stringify(analysis, null, 2));
// Step 5: Auto-assign primary roles to the active profile
const initialRoleMap: Record<string, string> = {};
for (const role of analysis.crossFileRoles) {
if (role.isPrimaryPerson) {
initialRoleMap[role.roleLabel] = knowledge.activeProfileName;
}
}
setState((s) => ({
...s,
phase: 'done',
analysis,
roleProfileMap: initialRoleMap,
fileRoleOverrides: {},
}));
} catch (err) {
setState((s) => ({
...s,
phase: 'error',
error: err instanceof Error ? err.message : 'Form analysis failed.',
}));
}
}, [knowledge.activeProfileName]);
const setRoleProfile = useCallback((roleLabel: string, profileName: string) => {
setState((s) => ({
...s,
roleProfileMap: { ...s.roleProfileMap, [roleLabel]: profileName },
}));
}, []);
const setFileRoleOverride = useCallback(
(fileId: string, roleLabel: string, profileName: string) => {
setState((s) => ({
...s,
fileRoleOverrides: {
...s.fileRoleOverrides,
[fileId]: { ...(s.fileRoleOverrides[fileId] || {}), [roleLabel]: profileName },
},
}));
},
[]
);
const clearFileRoleOverride = useCallback((fileId: string, roleLabel: string) => {
setState((s) => {
const overrides = { ...s.fileRoleOverrides };
if (overrides[fileId]) {
const roleOverrides = { ...overrides[fileId] };
delete roleOverrides[roleLabel];
if (Object.keys(roleOverrides).length === 0) {
delete overrides[fileId];
} else {
overrides[fileId] = roleOverrides;
}
}
return { ...s, fileRoleOverrides: overrides };
});
}, []);
const getEffectiveProfile = useCallback(
(fileId: string, roleLabel: string): string | undefined => {
return state.fileRoleOverrides[fileId]?.[roleLabel] || state.roleProfileMap[roleLabel];
},
[state.fileRoleOverrides, state.roleProfileMap]
);
const reset = useCallback(() => {
setState(INITIAL_STATE);
}, []);
return {
...state,
analyseAllFiles,
setRoleProfile,
setFileRoleOverride,
clearFileRoleOverride,
getEffectiveProfile,
reset,
};
}
@@ -0,0 +1,112 @@
/**
* Compatibility wrapper over useEntityStore.
* Maps the old profile-based API to the new entity store.
* Existing consumers (single-file flow) work unchanged.
*/
import { useCallback } from 'react';
import { useEntityStore, type EntityStore } from './useEntityStore';
export interface KnowledgeStore {
profileNames: string[];
activeProfileName: string;
entries: Record<string, string>;
entryCount: number;
setActiveProfile: (name: string) => void;
createProfile: (name: string) => void;
renameProfile: (oldName: string, newName: string) => void;
deleteProfile: (name: string) => void;
set: (key: string, value: string) => void;
setMany: (newEntries: Record<string, string>) => void;
remove: (key: string) => void;
clear: () => void;
/** Access to the underlying entity store */
entityStore: EntityStore;
}
export function useKnowledgeStore(): KnowledgeStore {
const store = useEntityStore();
const defaultEntity = store.defaultEntityId ? store.getEntity(store.defaultEntityId) : undefined;
const entries = defaultEntity?.fields || {};
const profileNames = store.entities.map((e) => e.name);
const activeProfileName = defaultEntity?.name || '';
const findByName = useCallback(
(name: string) => store.entities.find((e) => e.name === name),
[store.entities]
);
const setActiveProfile = useCallback(
(name: string) => {
const entity = findByName(name);
if (entity) store.setDefaultEntity(entity.id);
},
[findByName, store.setDefaultEntity]
);
const createProfile = useCallback(
(name: string) => {
const entity = store.createEntity('person', name);
store.setDefaultEntity(entity.id);
},
[store]
);
const renameProfile = useCallback(
(oldName: string, newName: string) => {
const entity = findByName(oldName);
if (entity) store.updateEntity(entity.id, { name: newName });
},
[findByName, store.updateEntity]
);
const deleteProfile = useCallback(
(name: string) => {
const entity = findByName(name);
if (entity) store.deleteEntity(entity.id);
},
[findByName, store.deleteEntity]
);
const set = useCallback(
(key: string, value: string) => {
if (store.defaultEntityId) store.setField(store.defaultEntityId, key, value);
},
[store.defaultEntityId, store.setField]
);
const setMany = useCallback(
(newEntries: Record<string, string>) => {
if (store.defaultEntityId) store.setManyFields(store.defaultEntityId, newEntries);
},
[store.defaultEntityId, store.setManyFields]
);
const remove = useCallback(
(key: string) => {
if (store.defaultEntityId) store.removeField(store.defaultEntityId, key);
},
[store.defaultEntityId, store.removeField]
);
const clear = useCallback(() => {
if (store.defaultEntityId) store.clearFields(store.defaultEntityId);
}, [store.defaultEntityId, store.clearFields]);
return {
profileNames,
activeProfileName,
entries,
entryCount: Object.keys(entries).filter((k) => !k.startsWith('_')).length,
setActiveProfile,
createProfile,
renameProfile,
deleteProfile,
set,
setMany,
remove,
clear,
entityStore: store,
};
}
@@ -0,0 +1,116 @@
/**
* Passive learning — detects manual edits after AI fill and offers to save them back to entities.
* Compares AI-filled values against current form values to find user changes.
*/
import { useState, useCallback } from 'react';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import type { EntityStore } from './useEntityStore';
import type { PreviewField } from './useBatchFormFillFlow';
export interface LearnedField {
fieldName: string;
originalValue: string;
newValue: string;
suggestedEntityId: string | null;
suggestedEntityName: string | null;
accepted: boolean;
}
export interface PassiveLearning {
learnedFields: LearnedField[];
detectChanges: (appliedFields: PreviewField[], entityStore: EntityStore) => void;
toggleField: (index: number) => void;
setEntityForField: (index: number, entityId: string) => void;
commitLearned: (entityStore: EntityStore) => void;
reset: () => void;
hasChanges: boolean;
}
export function usePassiveLearning(): PassiveLearning {
const [learnedFields, setLearnedFields] = useState<LearnedField[]>([]);
const formFill = useFormFill();
const detectChanges = useCallback(
(appliedFields: PreviewField[], entityStore: EntityStore) => {
const changes: LearnedField[] = [];
for (const field of appliedFields) {
if (!field.accepted) continue;
const currentValue = formFill.getValue(field.fieldName);
if (currentValue && currentValue !== field.value) {
// User changed this field after AI fill
const sourceEntity = entityStore.entities.find((e) => e.name === field.entityName);
changes.push({
fieldName: field.fieldName,
originalValue: field.value,
newValue: currentValue,
suggestedEntityId: sourceEntity?.id || null,
suggestedEntityName: sourceEntity?.name || null,
accepted: true,
});
}
}
// Also check for new values in fields that were NOT filled by AI
// (user typed into a blank field)
for (const formField of formFill.state.fields) {
const wasAiFilled = appliedFields.some((f) => f.fieldName === formField.name && f.accepted);
if (wasAiFilled) continue;
const currentValue = formFill.getValue(formField.name);
if (currentValue && currentValue !== formField.value) {
changes.push({
fieldName: formField.name,
originalValue: '',
newValue: currentValue,
suggestedEntityId: entityStore.defaultEntityId,
suggestedEntityName: entityStore.defaultEntityId
? entityStore.getEntity(entityStore.defaultEntityId)?.name || null
: null,
accepted: true,
});
}
}
setLearnedFields(changes);
},
[formFill]
);
const toggleField = useCallback((index: number) => {
setLearnedFields((prev) =>
prev.map((f, i) => (i === index ? { ...f, accepted: !f.accepted } : f))
);
}, []);
const setEntityForField = useCallback((index: number, entityId: string) => {
setLearnedFields((prev) =>
prev.map((f, i) => (i === index ? { ...f, suggestedEntityId: entityId } : f))
);
}, []);
const commitLearned = useCallback(
(entityStore: EntityStore) => {
for (const field of learnedFields) {
if (!field.accepted || !field.suggestedEntityId) continue;
// Use the field name as the knowledge key (normalize it)
const key = field.fieldName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
entityStore.setField(field.suggestedEntityId, key, field.newValue);
}
setLearnedFields([]);
},
[learnedFields]
);
const reset = useCallback(() => setLearnedFields([]), []);
return {
learnedFields,
detectChanges,
toggleField,
setEntityForField,
commitLearned,
reset,
hasChanges: learnedFields.length > 0,
};
}
@@ -0,0 +1,227 @@
/**
* Saved workflow templates — remember form + role + entity mappings for repeat use.
* Also handles dynamic values ({{today}}, date formatting) and quick fill.
*/
const TEMPLATES_STORAGE_KEY = 'stirling-pdf-ai-workflows';
export interface WorkflowTemplate {
id: string;
name: string;
/** Hash of the form field structure — identifies the form type */
formSignature: string;
/** Role → entity ID mappings */
roleEntityMap: Record<string, string>;
/** Per-file overrides (fileSignature → role → entityId) */
fileOverrides: Record<string, Record<string, string>>;
createdAt: number;
lastUsedAt: number;
}
export interface WorkflowStore {
templates: WorkflowTemplate[];
findBySignature: (signature: string) => WorkflowTemplate | undefined;
save: (template: Omit<WorkflowTemplate, 'id' | 'createdAt' | 'lastUsedAt'>) => WorkflowTemplate;
updateLastUsed: (id: string) => void;
remove: (id: string) => void;
removeAll: () => void;
}
/** Generate a signature from form field names to identify a form type */
export function generateFormSignature(fieldNames: string[]): string {
const sorted = [...fieldNames].sort();
// Simple hash — use first 20 field names joined, then hash
const source = sorted.slice(0, 20).join('|');
let hash = 0;
for (let i = 0; i < source.length; i++) {
const char = source.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return `form_${Math.abs(hash).toString(36)}`;
}
function loadTemplates(): WorkflowTemplate[] {
try {
const raw = localStorage.getItem(TEMPLATES_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveTemplates(templates: WorkflowTemplate[]): void {
try {
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates));
} catch { /* QuotaExceededError */ }
}
export function createWorkflowStore(): WorkflowStore {
let templates = loadTemplates();
return {
get templates() { return templates; },
findBySignature(signature: string) {
return templates.find((t) => t.formSignature === signature);
},
save(template) {
const full: WorkflowTemplate = {
...template,
id: crypto.randomUUID(),
createdAt: Date.now(),
lastUsedAt: Date.now(),
};
templates = [...templates, full];
saveTemplates(templates);
return full;
},
updateLastUsed(id: string) {
templates = templates.map((t) =>
t.id === id ? { ...t, lastUsedAt: Date.now() } : t
);
saveTemplates(templates);
},
remove(id: string) {
templates = templates.filter((t) => t.id !== id);
saveTemplates(templates);
},
removeAll() {
templates = [];
saveTemplates(templates);
},
};
}
// --- Dynamic Values ---
const DYNAMIC_PATTERNS: Record<string, () => string> = {
'{{today}}': () => new Date().toISOString().split('T')[0],
'{{today_uk}}': () => {
const d = new Date();
return `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getFullYear()}`;
},
'{{today_us}}': () => {
const d = new Date();
return `${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getDate().toString().padStart(2, '0')}/${d.getFullYear()}`;
},
'{{today_long}}': () => new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }),
'{{year}}': () => new Date().getFullYear().toString(),
};
/** Resolve dynamic value patterns in entity field values */
export function resolveDynamicValues(knowledge: Record<string, string>): Record<string, string> {
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(knowledge)) {
let resolvedValue = value;
for (const [pattern, resolver] of Object.entries(DYNAMIC_PATTERNS)) {
if (resolvedValue.includes(pattern)) {
resolvedValue = resolvedValue.replace(pattern, resolver());
}
}
resolved[key] = resolvedValue;
}
return resolved;
}
// --- Expiry Date Checking ---
export interface ExpiryWarning {
entityName: string;
fieldKey: string;
expiryDate: string;
daysUntilExpiry: number;
isExpired: boolean;
}
/** Check all entities for expiring certifications/fields */
export function checkExpiryDates(
entities: Array<{ name: string; fields: Record<string, string> }>,
warningDaysThreshold: number = 30
): ExpiryWarning[] {
const warnings: ExpiryWarning[] = [];
const expiryKeyPatterns = ['expiry_date', 'expiration_date', 'expires', 'valid_until', 'renewal_date'];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const entity of entities) {
for (const [key, value] of Object.entries(entity.fields)) {
const isExpiryField = expiryKeyPatterns.some((p) => key.toLowerCase().includes(p));
if (!isExpiryField) continue;
const parsed = new Date(value);
if (isNaN(parsed.getTime())) continue;
parsed.setHours(0, 0, 0, 0);
const diffMs = parsed.getTime() - today.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays <= warningDaysThreshold) {
warnings.push({
entityName: entity.name,
fieldKey: key,
expiryDate: value,
daysUntilExpiry: diffDays,
isExpired: diffDays < 0,
});
}
}
}
return warnings.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
}
// --- Cross-Form Consistency ---
export interface ConsistencyIssue {
fieldLabel: string;
values: Array<{ fileId: string; fileName: string; value: string }>;
suggestedValue: string;
}
/** Check for inconsistent values across files for the same field */
export function checkCrossFormConsistency(
fillResults: Array<{ fileId: string; fileName: string; filledFields: Array<{ fieldName: string; value: string }> }>
): ConsistencyIssue[] {
// Group values by field name (normalized)
const fieldValues: Record<string, Array<{ fileId: string; fileName: string; value: string }>> = {};
for (const result of fillResults) {
for (const field of result.filledFields) {
const normalizedName = field.fieldName.toLowerCase().replace(/[^a-z0-9]/g, '_');
if (!fieldValues[normalizedName]) fieldValues[normalizedName] = [];
fieldValues[normalizedName].push({
fileId: result.fileId,
fileName: result.fileName,
value: field.value,
});
}
}
// Find fields that appear in multiple files with different values
const issues: ConsistencyIssue[] = [];
for (const [fieldName, entries] of Object.entries(fieldValues)) {
if (entries.length < 2) continue;
const uniqueValues = [...new Set(entries.map((e) => e.value))];
if (uniqueValues.length <= 1) continue;
// Most common value is the suggestion
const valueCounts = new Map<string, number>();
for (const e of entries) {
valueCounts.set(e.value, (valueCounts.get(e.value) || 0) + 1);
}
const suggestedValue = [...valueCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
issues.push({
fieldLabel: fieldName,
values: entries,
suggestedValue,
});
}
return issues;
}
+1
View File
@@ -58,6 +58,7 @@ export const CORE_REGULAR_TOOL_IDS = [
'bookletImposition',
'pdfTextEditor',
'formFill',
'aiFormFill',
] as const;
export const CORE_SUPER_TOOL_IDS = [
@@ -71,6 +71,7 @@ export const TOOL_CREDIT_COSTS: Record<ToolId, number> = {
replaceColor: CREDIT_COSTS.MEDIUM,
annotate: CREDIT_COSTS.MEDIUM,
formFill: CREDIT_COSTS.MEDIUM,
aiFormFill: CREDIT_COSTS.MEDIUM,
// Large operations (5 credits)
compress: CREDIT_COSTS.LARGE,
+16 -7
View File
@@ -29,6 +29,9 @@ export default defineConfig(({ mode }) => {
const tsconfigProject = TSCONFIG_MAP[effectiveMode];
const backendTarget = `http://localhost:${env.VITE_BACKEND_PORT || '8080'}`;
const aiEngineTarget = `http://localhost:${env.VITE_AI_ENGINE_PORT || '5001'}`;
return {
plugins: [
react(),
@@ -63,47 +66,53 @@ export default defineConfig(({ mode }) => {
// Only use proxy in web mode - Tauri handles backend connections directly
proxy: effectiveMode === 'desktop' ? undefined : {
'/api': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/oauth2': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/saml2': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/login/oauth2': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/login/saml2': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/swagger-ui': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/v1/api-docs': {
target: 'http://localhost:8080',
target: backendTarget,
changeOrigin: true,
secure: false,
xfwd: true,
},
'/engine-api': {
target: aiEngineTarget,
changeOrigin: true,
secure: false,
rewrite: (path: string) => path.replace(/^\/engine-api/, ''),
},
},
},
base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : './',