Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
.idea
venv
.venv
*.db
*.db
faiss_index.bin
incidents.pkl
3 changes: 2 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import FastAPI
from api.routes import templates, forms
from api.similarity_api import router as similarity_router

app = FastAPI()

app.include_router(similarity_router)
app.include_router(templates.router)
app.include_router(forms.router)
15 changes: 15 additions & 0 deletions api/similarity_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from src.incident_similarity import IncidentSimilarity

router = APIRouter()

similarity_engine = IncidentSimilarity()


@router.get("/similar-incidents")
def get_similar_incidents(query: str, top_k: int = 3):
results = similarity_engine.search(query, top_k)
return {
"query": query,
"results": results
}
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
Empty file removed src/__init__.py
Empty file.
50 changes: 45 additions & 5 deletions src/controller.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,51 @@
from typing import List, Dict, Any
from src.file_manipulator import FileManipulator
from src.timeline_extractor import TimelineExtractor
from src.incident_similarity import IncidentSimilarity

# Initialize globally (important)
similarity_engine = IncidentSimilarity()


class Controller:
def __init__(self):
"""
Controller layer for FireForm.
Responsible for orchestrating the processing pipeline.
"""

def __init__(self) -> None:
self.file_manipulator = FileManipulator()
self.timeline_extractor = TimelineExtractor()

def fill_form(
self,
user_input: str,
fields: List[str],
pdf_form_path: str
) -> Dict[str, Any]:

# 1. Extract timeline
timeline = self.timeline_extractor.extract_timeline(user_input)

# 2. Perform similarity search BEFORE adding new incident
similar_cases = similarity_engine.search(user_input)

# 3. Fill form
result = self.file_manipulator.fill_form(
user_input,
fields,
pdf_form_path
)

# 4. Store new incident AFTER search
similarity_engine.add_incident(user_input)

# 5. Attach results
if isinstance(result, dict):
result["timeline"] = timeline
result["similar_incidents"] = similar_cases

return result

def fill_form(self, user_input: str, fields: list, pdf_form_path: str):
return self.file_manipulator.fill_form(user_input, fields, pdf_form_path)

def create_template(self, pdf_path: str):
def create_template(self, pdf_path: str) -> Dict[str, Any]:
return self.file_manipulator.create_template(pdf_path)
1 change: 1 addition & 0 deletions src/django
Submodule django added at 3abf89
63 changes: 63 additions & 0 deletions src/incident_similarity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
import os
import pickle


class IncidentSimilarity:
def __init__(self, index_path="faiss_index.bin", data_path="incidents.pkl"):
self.model = SentenceTransformer("all-MiniLM-L6-v2")
self.dimension = 384

self.index_path = index_path
self.data_path = data_path

self.index = faiss.IndexFlatL2(self.dimension)
self.incidents = []

self._load()

def add_incident(self, text: str):
embedding = self.model.encode([text])
embedding = np.array(embedding).astype("float32")

self.index.add(embedding)
self.incidents.append(text)

self._save()

def search(self, query: str, top_k: int = 3):
if len(self.incidents) == 0:
return []

query_embedding = self.model.encode([query])
query_embedding = np.array(query_embedding).astype("float32")

distances, indices = self.index.search(query_embedding, top_k)

results = []

for i, idx in enumerate(indices[0]):
if idx < len(self.incidents):
distance = distances[0][i]
similarity_score = 1 / (1 + distance)
results.append({
"incident": self.incidents[idx],
"score": round(similarity_score, 4)
})

return results

def _save(self):
faiss.write_index(self.index, self.index_path)
with open(self.data_path, "wb") as f:
pickle.dump(self.incidents, f)

def _load(self):
if os.path.exists(self.index_path):
self.index = faiss.read_index(self.index_path)

if os.path.exists(self.data_path):
with open(self.data_path, "rb") as f:
self.incidents = pickle.load(f)
Binary file added src/inputs/file_template.pdf
Binary file not shown.
8 changes: 4 additions & 4 deletions src/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def build_prompt(self, current_field):
return prompt

def main_loop(self):
# self.type_check_all()
self.type_check_all()
for field in self._target_fields.keys():
prompt = self.build_prompt(field)
# print(prompt)
Expand All @@ -54,13 +54,13 @@ def main_loop(self):
ollama_url = f"{ollama_host}/api/generate"

payload = {
"model": "mistral",
"model" : os.getenv("OLLAMA_MODEL", "mistral"),
"prompt": prompt,
"stream": False, # don't really know why --> look into this later.
}

try:
response = requests.post(ollama_url, json=payload)
response = requests.post(ollama_url, json=payload, timeout=30)
response.raise_for_status()
except requests.exceptions.ConnectionError:
raise ConnectionError(
Expand All @@ -72,7 +72,7 @@ def main_loop(self):

# parse response
json_data = response.json()
parsed_response = json_data["response"]
parsed_response = json_data.get("response", "")
# print(parsed_response)
self.add_response_to_json(field, parsed_response)

Expand Down
1 change: 1 addition & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# from backend import Fill
from commonforms import prepare_form
from pypdf import PdfReader
from typing import Union
from controller import Controller

def input_fields(num_fields: int):
Expand Down
93 changes: 93 additions & 0 deletions src/test/test_controller_timeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import pytest
from unittest.mock import MagicMock
from src.controller import Controller


class TestControllerTimeline:
"""
Test suite for verifying timeline extraction integration
within the FireForm controller pipeline.
"""

@pytest.fixture
def controller(self):
"""
Provides a Controller instance with mocked FileManipulator.
"""
controller = Controller()
controller.file_manipulator = MagicMock()

# Simulate file_manipulator returning a valid result dict
controller.file_manipulator.fill_form.return_value = {
"status": "success",
"filled_pdf": "output.pdf"
}

return controller

def test_timeline_extraction_integration(self, controller):
"""
Ensure timeline data is added to controller output.
"""

incident_text = (
"Engine 12 arrived at 3:10 PM. "
"Fire contained at 3:25 PM."
)

result = controller.fill_form(
user_input=incident_text,
fields=[],
pdf_form_path="dummy.pdf"
)

assert isinstance(result, dict)
assert "timeline" in result
assert len(result["timeline"]) == 2
assert result["timeline"][0]["time"] == "15:10"
assert result["timeline"][1]["time"] == "15:25"

def test_no_timeline_when_no_times(self, controller):
"""
Ensure timeline is empty when no timestamps exist.
"""

incident_text = "Firefighters responded quickly to the incident."

result = controller.fill_form(
user_input=incident_text,
fields=[],
pdf_form_path="dummy.pdf"
)

assert "timeline" in result
assert result["timeline"] == []

def test_controller_pipeline_still_calls_file_manipulator(self, controller):
"""
Ensure existing pipeline behavior is preserved.
"""

incident_text = "Engine arrived at 3:10 PM."

controller.fill_form(
user_input=incident_text,
fields=["name", "location"],
pdf_form_path="incident_form.pdf"
)

controller.file_manipulator.fill_form.assert_called_once()

def test_invalid_input_handling(self, controller):
"""
Ensure controller handles invalid input gracefully.
"""

result = controller.fill_form(
user_input="",
fields=[],
pdf_form_path="dummy.pdf"
)

assert isinstance(result, dict)
assert "timeline" in result
Loading