Improvements
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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(/&/g, '&');
|
||||
cleaned = cleaned.replace(/'/g, "'");
|
||||
cleaned = cleaned.replace(/"/g, '"');
|
||||
cleaned = cleaned.replace(/</g, '<');
|
||||
cleaned = cleaned.replace(/>/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(/&/g, '&');
|
||||
cleaned = cleaned.replace(/'/g, "'");
|
||||
cleaned = cleaned.replace(/"/g, '"');
|
||||
cleaned = cleaned.replace(/</g, '<');
|
||||
cleaned = cleaned.replace(/>/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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}` : './',
|
||||
|
||||
Reference in New Issue
Block a user