Álvaro Valenzuela Valdes commited on
Commit
26fba59
·
1 Parent(s): df23d27

feat: integrate buyer risk intelligence and scraped tender details into agent analysis

Browse files
backend/app/main.py CHANGED
@@ -9,7 +9,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
 
10
  from fastapi import FastAPI
11
  from fastapi.middleware.cors import CORSMiddleware
12
- from app.routers import analysis, company, health, tenders, documents, oc
13
  from app.database import engine, Base, SessionLocal, SQLALCHEMY_DATABASE_URL
14
  from app.models.tender import TenderModel
15
  from app.models.analysis import AnalysisHistoryModel
@@ -48,6 +48,7 @@ app.include_router(analysis.router, prefix="/api", tags=["Analysis"])
48
  app.include_router(company.router, prefix="/api", tags=["Company"])
49
  app.include_router(documents.router, prefix="/api", tags=["Documents"])
50
  app.include_router(oc.router, prefix="/api", tags=["Purchase Orders"])
 
51
 
52
  @app.on_event("startup")
53
  async def startup_event():
 
9
 
10
  from fastapi import FastAPI
11
  from fastapi.middleware.cors import CORSMiddleware
12
+ from app.routers import analysis, company, health, tenders, documents, oc, tender_details
13
  from app.database import engine, Base, SessionLocal, SQLALCHEMY_DATABASE_URL
14
  from app.models.tender import TenderModel
15
  from app.models.analysis import AnalysisHistoryModel
 
48
  app.include_router(company.router, prefix="/api", tags=["Company"])
49
  app.include_router(documents.router, prefix="/api", tags=["Documents"])
50
  app.include_router(oc.router, prefix="/api", tags=["Purchase Orders"])
51
+ app.include_router(tender_details.router, prefix="/api", tags=["Tender Details"])
52
 
53
  @app.on_event("startup")
54
  async def startup_event():
backend/app/models/tender.py CHANGED
@@ -26,6 +26,8 @@ class TenderModel(Base):
26
  attachments = Column(JSON, nullable=True)
27
  evaluation_criteria = Column(JSON, nullable=True)
28
  contract_duration = Column(String(255), nullable=True)
 
 
29
 
30
  # Metadata for the app logic
31
  last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 
26
  attachments = Column(JSON, nullable=True)
27
  evaluation_criteria = Column(JSON, nullable=True)
28
  contract_duration = Column(String(255), nullable=True)
29
+ detail_tabs = Column(JSON, nullable=True) # NEW: Extracted detail tabs
30
+ detail_metadata = Column(JSON, nullable=True) # NEW: Aggregated metadata
31
 
32
  # Metadata for the app logic
33
  last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
backend/app/models/tender_detail.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, String, DateTime, JSON, Text, ForeignKey
2
+ from app.database import Base
3
+ from datetime import datetime
4
+
5
+ class TenderDetailTabModel(Base):
6
+ """Store extracted detail tabs from tender pages"""
7
+ __tablename__ = "tender_detail_tabs"
8
+
9
+ id = Column(String(100), primary_key=True) # "{tender_code}_{tab_name}"
10
+ tender_code = Column(String(50), ForeignKey('tenders.code'), index=True)
11
+ tab_name = Column(String(100)) # Preguntas, Historial, Apertura, Adjudicación, Antecedentes, etc.
12
+ tab_type = Column(String(50)) # questions, history, opening, adjudication, attachments, criteria
13
+ content_summary = Column(Text) # Summary of tab content
14
+ metadata = Column(JSON, nullable=True) # Tab-specific data (counts, dates, etc.)
15
+ attachment_urls = Column(JSON, nullable=True) # List of attachment URLs for this tab
16
+ last_fetched = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
17
+ html_content = Column(Text, nullable=True) # Optional: store raw HTML for later parsing
18
+
19
+ class TenderAttachmentDetailModel(Base):
20
+ """Detailed information about tender attachments"""
21
+ __tablename__ = "tender_attachment_details"
22
+
23
+ id = Column(String(100), primary_key=True) # Unique hash of URL
24
+ tender_code = Column(String(50), ForeignKey('tenders.code'), index=True)
25
+ attachment_name = Column(String(255), index=True)
26
+ attachment_url = Column(Text)
27
+ tab_category = Column(String(100)) # Administrativo, Técnico, Económico, etc.
28
+ file_type = Column(String(50)) # PDF, DOC, XLS, etc.
29
+ estimated_size = Column(String(50), nullable=True) # For reference
30
+ last_updated = Column(DateTime, default=datetime.utcnow)
31
+ is_accessible = Column(JSON, nullable=True) # Track if URL is still valid
backend/app/routers/analysis.py CHANGED
@@ -16,7 +16,7 @@ analysis_history: List[AnalysisRecord] = load_from_json(AnalysisRecord, "analysi
16
 
17
  @router.post("/analyze", response_model=AnalysisResult)
18
  async def analyze_opportunity(request: AnalysisRequest):
19
- result = await run_full_analysis(request.tender, request.company_profile, request.document_text, request.models)
20
  record = AnalysisRecord(
21
  tender_code=request.tender.code,
22
  tender_name=request.tender.name,
 
16
 
17
  @router.post("/analyze", response_model=AnalysisResult)
18
  async def analyze_opportunity(request: AnalysisRequest):
19
+ result = await run_full_analysis(request.tender, request.company_profile, request.document_text, request.models, request.tender_details)
20
  record = AnalysisRecord(
21
  tender_code=request.tender.code,
22
  tender_name=request.tender.name,
backend/app/routers/oc.py CHANGED
@@ -5,6 +5,7 @@ from app.schemas.oc import PurchaseOrder
5
  from app.database import get_db
6
  from app.models.oc import OCModel
7
  from app.services.mercado_publico_oc import get_ocs_by_date, get_oc_by_code
 
8
 
9
  router = APIRouter()
10
 
@@ -20,10 +21,24 @@ async def list_purchase_orders(
20
  if not date:
21
  from datetime import datetime
22
  date = datetime.now().strftime("%d%m%Y")
23
-
24
- # Check if we have them in DB first?
25
- # For now, let's just fetch from API as a real-time monitor
26
- return await get_ocs_by_date(date, status)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  @router.get("/purchase-orders/{code}", response_model=Optional[PurchaseOrder])
29
  async def get_purchase_order(code: str):
 
5
  from app.database import get_db
6
  from app.models.oc import OCModel
7
  from app.services.mercado_publico_oc import get_ocs_by_date, get_oc_by_code
8
+ from app.services.sync import sync_purchase_orders_to_db
9
 
10
  router = APIRouter()
11
 
 
21
  if not date:
22
  from datetime import datetime
23
  date = datetime.now().strftime("%d%m%Y")
24
+
25
+ # Try to fetch current OC data from the live API
26
+ ocs = await get_ocs_by_date(date, status)
27
+ if ocs:
28
+ await sync_purchase_orders_to_db(db, date, status)
29
+ return ocs
30
+
31
+ # Fallback to cached DB entries when the API returns no results
32
+ db_results = db.query(OCModel).order_by(OCModel.date_creation.desc()).all()
33
+ return db_results
34
+
35
+ @router.post("/purchase-orders/sync")
36
+ async def sync_purchase_orders(
37
+ date: Optional[str] = None,
38
+ status: str = "todos",
39
+ db: Session = Depends(get_db)
40
+ ):
41
+ return await sync_purchase_orders_to_db(db, date, status)
42
 
43
  @router.get("/purchase-orders/{code}", response_model=Optional[PurchaseOrder])
44
  async def get_purchase_order(code: str):
backend/app/routers/tender_details.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router for tender detail tab extraction and management
3
+ """
4
+ from typing import Optional
5
+ from fastapi import APIRouter, Query, Depends
6
+ from sqlalchemy.orm import Session
7
+ from app.database import get_db
8
+ from app.services.tender_detail_extractor import extract_tender_detail_tabs, extract_all_attachments_for_tender
9
+ from app.models.tender_detail import TenderDetailTabModel, TenderAttachmentDetailModel
10
+
11
+ router = APIRouter()
12
+
13
+ @router.get("/tenders/{code}/detail-tabs")
14
+ async def get_tender_detail_tabs(
15
+ code: str,
16
+ qs: Optional[str] = Query(None, description="Encrypted detail parameter from MP"),
17
+ db: Session = Depends(get_db)
18
+ ):
19
+ """
20
+ Extract detail tabs for a tender.
21
+ Supports both code-based and qs-parameter (encrypted) lookups.
22
+ """
23
+ detail_info = await extract_tender_detail_tabs(code, qs)
24
+ return detail_info
25
+
26
+ @router.get("/tenders/{code}/attachments")
27
+ async def get_tender_attachments(
28
+ code: str,
29
+ qs: Optional[str] = Query(None),
30
+ ):
31
+ """
32
+ Get all public attachment URLs for a tender.
33
+ These URLs can be used to fetch documents without authentication.
34
+ """
35
+ attachments = await extract_all_attachments_for_tender(code, qs)
36
+ return {"tender_code": code, "attachments": attachments}
37
+
38
+ @router.post("/tenders/{code}/extract-details")
39
+ async def extract_and_save_detail_tabs(
40
+ code: str,
41
+ qs: Optional[str] = Query(None),
42
+ db: Session = Depends(get_db)
43
+ ):
44
+ """
45
+ Extract detail tabs and save to database for caching.
46
+ """
47
+ detail_info = await extract_tender_detail_tabs(code, qs)
48
+ if "error" in detail_info:
49
+ return {"status": "error", "message": detail_info["error"]}
50
+
51
+ # Save tabs to database
52
+ for tab_type, tab_data in detail_info.get("tabs", {}).items():
53
+ tab_id = f"{code}_{tab_type}"
54
+ existing = db.query(TenderDetailTabModel).filter(TenderDetailTabModel.id == tab_id).first()
55
+ if not existing:
56
+ tab_entry = TenderDetailTabModel(
57
+ id=tab_id,
58
+ tender_code=code,
59
+ tab_name=tab_data.get("name"),
60
+ tab_type=tab_type,
61
+ metadata=tab_data
62
+ )
63
+ db.add(tab_entry)
64
+
65
+ # Save attachments
66
+ for att in detail_info.get("attachments", []):
67
+ att_id = f"{code}_{att.get('name', 'unknown').replace('/', '_')}"
68
+ existing = db.query(TenderAttachmentDetailModel).filter(TenderAttachmentDetailModel.id == att_id).first()
69
+ if not existing:
70
+ att_entry = TenderAttachmentDetailModel(
71
+ id=att_id,
72
+ tender_code=code,
73
+ attachment_name=att.get("name"),
74
+ attachment_url=att.get("href"),
75
+ tab_category="Unknown"
76
+ )
77
+ db.add(att_entry)
78
+
79
+ db.commit()
80
+ return {"status": "success", "detail_info": detail_info}
backend/app/schemas/analysis.py CHANGED
@@ -43,6 +43,7 @@ class AnalysisRequest(BaseModel):
43
  company_profile: CompanyProfile
44
  document_text: str | None = None
45
  models: dict | None = None
 
46
 
47
 
48
  class AnalysisResult(BaseModel):
 
43
  company_profile: CompanyProfile
44
  document_text: str | None = None
45
  models: dict | None = None
46
+ tender_details: dict | None = None
47
 
48
 
49
  class AnalysisResult(BaseModel):
backend/app/schemas/tender.py CHANGED
@@ -14,6 +14,16 @@ class TenderItem(BaseModel):
14
  class TenderAttachment(BaseModel):
15
  name: str
16
  url: str
 
 
 
 
 
 
 
 
 
 
17
 
18
  class Tender(BaseModel):
19
  model_config = ConfigDict(from_attributes=True)
@@ -37,4 +47,6 @@ class Tender(BaseModel):
37
  attachments: List[TenderAttachment] = []
38
  evaluation_criteria: List[dict] = []
39
  contract_duration: Optional[str] = None
 
 
40
  raw_data: Optional[dict] = None # Store the full response if needed
 
14
  class TenderAttachment(BaseModel):
15
  name: str
16
  url: str
17
+ category: Optional[str] = None # Administrativo, Técnico, Económico, etc.
18
+ file_type: Optional[str] = None # PDF, DOC, XLS, etc.
19
+
20
+ class TenderDetailTab(BaseModel):
21
+ """Detail tab information (Preguntas, Historial, Apertura, Adjudicación, etc.)"""
22
+ tab_name: str
23
+ tab_type: str # questions, history, opening, adjudication
24
+ content_summary: Optional[str] = None
25
+ metadata: Optional[dict] = None
26
+ attachment_urls: Optional[List[str]] = None
27
 
28
  class Tender(BaseModel):
29
  model_config = ConfigDict(from_attributes=True)
 
47
  attachments: List[TenderAttachment] = []
48
  evaluation_criteria: List[dict] = []
49
  contract_duration: Optional[str] = None
50
+ detail_tabs: List[TenderDetailTab] = [] # Detail tab information
51
+ detail_metadata: Optional[dict] = None # Aggregated detail metadata
52
  raw_data: Optional[dict] = None # Store the full response if needed
backend/app/services/agents.py CHANGED
@@ -6,39 +6,45 @@ from app.services.llm import call_gemini, _parse_gemini_response, call_gemini_wi
6
  from app.services.report import generate_markdown_report
7
  from app.config import settings
8
 
9
- async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
 
10
  prompt = (
11
  f"AGENT ROLE: Legal & Compliance Expert (Chilean Public Procurement)\n"
12
  f"GOAL: Analyze administrative bases and compliance risks.\n"
13
  f"TENDER: {tender.name} (Type: {tender.type})\n"
14
  f"COMPANY: {company.name}\n"
15
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
 
16
  f"TASK: Identify 3 legal gaps/risks. Respond in Spanish."
17
  )
18
  return await call_gemini_with_model(prompt, model)
19
 
20
- async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
 
21
  prompt = (
22
  f"AGENT ROLE: Technical Architect\n"
23
  f"GOAL: Evaluate technical feasibility.\n"
24
  f"TENDER: {tender.name} - {tender.description}\n"
25
  f"COMPANY: {company.industry} - {company.experience}\n"
26
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
 
27
  f"TASK: Identify 3 technical challenges. Respond in Spanish."
28
  )
29
  return await call_gemini_with_model(prompt, model)
30
 
31
- async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
 
32
  prompt = (
33
  f"AGENT ROLE: Risk & Strategy Specialist\n"
34
  f"GOAL: Calculate ROI and strategy.\n"
35
  f"TENDER: {tender.name}\n"
36
  f"COMPANY: {company.name}\n"
 
37
  f"TASK: Identify 3 strategic risks and a win strategy. Respond in Spanish."
38
  )
39
  return await call_gemini_with_model(prompt, model)
40
 
41
- async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
42
  audit_log = ["🚀 Iniciando mesa de expertos agéntica..."]
43
  doc_text = document_text or ""
44
 
@@ -54,9 +60,9 @@ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, doc
54
  audit_log.append(f"🕵️ Agente de Riesgo ({chosen_models.get('risk')})")
55
 
56
  tasks = [
57
- legal_agent_task(tender, company_profile, doc_text, chosen_models.get("legal")),
58
- technical_agent_task(tender, company_profile, doc_text, chosen_models.get("tech")),
59
- strategy_agent_task(tender, company_profile, doc_text, chosen_models.get("risk"))
60
  ]
61
 
62
  responses = await asyncio.gather(*tasks)
 
6
  from app.services.report import generate_markdown_report
7
  from app.config import settings
8
 
9
+ async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None, tender_details: dict | None = None) -> str:
10
+ details_str = f"\nSCRAPED DETAILS: {tender_details}" if tender_details else ""
11
  prompt = (
12
  f"AGENT ROLE: Legal & Compliance Expert (Chilean Public Procurement)\n"
13
  f"GOAL: Analyze administrative bases and compliance risks.\n"
14
  f"TENDER: {tender.name} (Type: {tender.type})\n"
15
  f"COMPANY: {company.name}\n"
16
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
17
+ f"{details_str}\n"
18
  f"TASK: Identify 3 legal gaps/risks. Respond in Spanish."
19
  )
20
  return await call_gemini_with_model(prompt, model)
21
 
22
+ async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None, tender_details: dict | None = None) -> str:
23
+ details_str = f"\nSCRAPED DETAILS: {tender_details}" if tender_details else ""
24
  prompt = (
25
  f"AGENT ROLE: Technical Architect\n"
26
  f"GOAL: Evaluate technical feasibility.\n"
27
  f"TENDER: {tender.name} - {tender.description}\n"
28
  f"COMPANY: {company.industry} - {company.experience}\n"
29
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
30
+ f"{details_str}\n"
31
  f"TASK: Identify 3 technical challenges. Respond in Spanish."
32
  )
33
  return await call_gemini_with_model(prompt, model)
34
 
35
+ async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None, tender_details: dict | None = None) -> str:
36
+ details_str = f"\nSCRAPED DETAILS: {tender_details}" if tender_details else ""
37
  prompt = (
38
  f"AGENT ROLE: Risk & Strategy Specialist\n"
39
  f"GOAL: Calculate ROI and strategy.\n"
40
  f"TENDER: {tender.name}\n"
41
  f"COMPANY: {company.name}\n"
42
+ f"{details_str}\n"
43
  f"TASK: Identify 3 strategic risks and a win strategy. Respond in Spanish."
44
  )
45
  return await call_gemini_with_model(prompt, model)
46
 
47
+ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, document_text: str | None = None, models: dict | None = None, tender_details: dict | None = None) -> AnalysisResult:
48
  audit_log = ["🚀 Iniciando mesa de expertos agéntica..."]
49
  doc_text = document_text or ""
50
 
 
60
  audit_log.append(f"🕵️ Agente de Riesgo ({chosen_models.get('risk')})")
61
 
62
  tasks = [
63
+ legal_agent_task(tender, company_profile, doc_text, chosen_models.get("legal"), tender_details),
64
+ technical_agent_task(tender, company_profile, doc_text, chosen_models.get("tech"), tender_details),
65
+ strategy_agent_task(tender, company_profile, doc_text, chosen_models.get("risk"), tender_details)
66
  ]
67
 
68
  responses = await asyncio.gather(*tasks)
backend/app/services/mercado_publico.py CHANGED
@@ -122,6 +122,9 @@ def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
122
  "Subsecretaría de Educación", "Servicio Nacional de Aduanas"
123
  ]
124
  buyer_fallback = institutions[code_hash % len(institutions)]
 
 
 
125
 
126
  # Extract Attachments
127
  attachments_list = []
@@ -154,9 +157,9 @@ def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
154
  description=item.get("Descripcion", item.get("Nombre", "")),
155
  buyer=buyer_name,
156
  buyer_region=item.get("Comprador", {}).get("RegionUnidad"),
157
- status=item.get("Estado", "Publicada"),
158
- status_code=item.get("CodigoEstado"),
159
- type=item.get("Tipo"),
160
  currency=item.get("Moneda"),
161
  closing_date=closing_date,
162
  publication_date=pub_date,
 
122
  "Subsecretaría de Educación", "Servicio Nacional de Aduanas"
123
  ]
124
  buyer_fallback = institutions[code_hash % len(institutions)]
125
+ buyer_name = item.get("Comprador", {}).get("Nombre") or buyer_fallback
126
+ status_code = item.get("CodigoEstado")
127
+ status_label = item.get("NombreEstado") or STATUS_CODES.get(str(status_code), "Publicada")
128
 
129
  # Extract Attachments
130
  attachments_list = []
 
157
  description=item.get("Descripcion", item.get("Nombre", "")),
158
  buyer=buyer_name,
159
  buyer_region=item.get("Comprador", {}).get("RegionUnidad"),
160
+ status=status_label,
161
+ status_code=int(status_code) if status_code and str(status_code).isdigit() else None,
162
+ type=item.get("Tipo") or item.get("CodigoTipo"),
163
  currency=item.get("Moneda"),
164
  closing_date=closing_date,
165
  publication_date=pub_date,
backend/app/services/mercado_publico_oc.py CHANGED
@@ -37,6 +37,16 @@ OC_TYPES = {
37
  "14": "CC - Compra Coordinada"
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
40
  def map_raw_to_oc(item: Dict[str, Any]) -> PurchaseOrder:
41
  # Handle items
42
  items_list = []
@@ -87,10 +97,22 @@ async def _fetch_oc(params: Dict[str, str], retries: int = 3) -> List[PurchaseOr
87
  if params.get("estado") == "todos":
88
  del params["estado"]
89
 
 
 
 
 
 
 
 
 
 
 
 
90
  async with mp_api_semaphore:
91
  for attempt in range(retries):
92
  try:
93
  async with httpx.AsyncClient(timeout=45.0) as client:
 
94
  response = await client.get(API_BASE_OC, params=params)
95
 
96
  if response.status_code == 500:
 
37
  "14": "CC - Compra Coordinada"
38
  }
39
 
40
+ OC_STATUS_ALIAS = {
41
+ "todos": None,
42
+ "aceptada": "6",
43
+ "enviadaproveedor": "4",
44
+ "enviadaaproveedor": "4",
45
+ "en proceso": "5",
46
+ "enproceso": "5",
47
+ "cancelada": "9"
48
+ }
49
+
50
  def map_raw_to_oc(item: Dict[str, Any]) -> PurchaseOrder:
51
  # Handle items
52
  items_list = []
 
97
  if params.get("estado") == "todos":
98
  del params["estado"]
99
 
100
+ # Map friendly status labels to Mercado Público status codes
101
+ if params.get("estado"):
102
+ lower_status = params["estado"].strip().lower()
103
+ mapped = OC_STATUS_ALIAS.get(lower_status)
104
+ if mapped is None and lower_status != "todos":
105
+ params["estado"] = mapped or params["estado"]
106
+ elif lower_status == "todos":
107
+ params.pop("estado", None)
108
+ else:
109
+ params["estado"] = mapped
110
+
111
  async with mp_api_semaphore:
112
  for attempt in range(retries):
113
  try:
114
  async with httpx.AsyncClient(timeout=45.0) as client:
115
+ print(f"[OC API] Fetching OC with params: {params}")
116
  response = await client.get(API_BASE_OC, params=params)
117
 
118
  if response.status_code == 500:
backend/app/services/sync.py CHANGED
@@ -1,7 +1,9 @@
1
  from sqlalchemy.orm import Session
2
  from datetime import datetime
3
  from app.models.tender import TenderModel
 
4
  from app.services.mercado_publico import fetch_tenders, get_tender_by_code
 
5
  import json
6
 
7
  async def sync_tenders_to_db(db: Session, keyword: str = None):
@@ -84,6 +86,63 @@ async def sync_tenders_to_db(db: Session, keyword: str = None):
84
  print(f"[Sync] Finished. New: {count_new}, Updated: {count_updated}")
85
  return {"new": count_new, "updated": count_updated}
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def clean_expired_tenders(db: Session):
88
  """
89
  Removes tenders where closing_date is in the past.
 
1
  from sqlalchemy.orm import Session
2
  from datetime import datetime
3
  from app.models.tender import TenderModel
4
+ from app.models.oc import OCModel
5
  from app.services.mercado_publico import fetch_tenders, get_tender_by_code
6
+ from app.services.mercado_publico_oc import get_ocs_by_date
7
  import json
8
 
9
  async def sync_tenders_to_db(db: Session, keyword: str = None):
 
86
  print(f"[Sync] Finished. New: {count_new}, Updated: {count_updated}")
87
  return {"new": count_new, "updated": count_updated}
88
 
89
+ async def sync_purchase_orders_to_db(db: Session, date: str = None, status: str = "todos"):
90
+ """
91
+ Fetches purchase orders from Mercado Público and saves them in the local database.
92
+ """
93
+ if not date:
94
+ date = datetime.now().strftime("%d%m%Y")
95
+
96
+ try:
97
+ api_orders = await get_ocs_by_date(date, status)
98
+ if not api_orders:
99
+ print(f"[Sync OC] No purchase orders found for date={date} status={status}")
100
+ return {"new": 0, "updated": 0, "message": "No purchase orders found"}
101
+ except Exception as e:
102
+ print(f"[Sync OC] API error: {e}")
103
+ return {"new": 0, "updated": 0, "message": f"API Error: {str(e)}"}
104
+
105
+ count_new = 0
106
+ count_updated = 0
107
+ seen_codes = set()
108
+ for oc in api_orders:
109
+ if oc.code in seen_codes:
110
+ continue
111
+ seen_codes.add(oc.code)
112
+
113
+ db_oc = db.query(OCModel).filter(OCModel.code == oc.code).first()
114
+
115
+ oc_data = {
116
+ "code": oc.code,
117
+ "name": oc.name,
118
+ "status": oc.status,
119
+ "status_code": oc.status_code,
120
+ "buyer": oc.buyer,
121
+ "buyer_rut": oc.buyer_rut,
122
+ "provider": oc.provider,
123
+ "provider_rut": oc.provider_rut,
124
+ "date_creation": oc.date_creation,
125
+ "total_amount": oc.total_amount,
126
+ "currency": oc.currency,
127
+ "type": oc.type,
128
+ "items": [item.model_dump() for item in oc.items] if oc.items else [],
129
+ "raw_data": oc.raw_data,
130
+ }
131
+
132
+ if db_oc:
133
+ for key, value in oc_data.items():
134
+ setattr(db_oc, key, value)
135
+ count_updated += 1
136
+ else:
137
+ new_oc = OCModel(**oc_data)
138
+ db.add(new_oc)
139
+ count_new += 1
140
+
141
+ db.commit()
142
+ print(f"[Sync OC] Finished. New: {count_new}, Updated: {count_updated}")
143
+ return {"new": count_new, "updated": count_updated}
144
+
145
+
146
  def clean_expired_tenders(db: Session):
147
  """
148
  Removes tenders where closing_date is in the past.
backend/app/services/tender_detail_extractor.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service to extract and persist tender detail tab information from Mercado Público.
3
+ Uses HTML parsing to extract visible content + attachment URLs.
4
+ """
5
+ import httpx
6
+ import re
7
+ from typing import List, Optional, Dict, Any
8
+ from html.parser import HTMLParser
9
+ from app.models.tender_detail import TenderDetailTabModel, TenderAttachmentDetailModel
10
+
11
+
12
+ class AttachmentLinkExtractor(HTMLParser):
13
+ """Extract attachment links from HTML tables"""
14
+ def __init__(self):
15
+ super().__init__()
16
+ self.attachments = []
17
+ self.in_row = False
18
+ self.current_row_data = {}
19
+
20
+ def handle_starttag(self, tag, attrs):
21
+ attrs_dict = dict(attrs)
22
+ if tag.lower() == 'tr':
23
+ self.in_row = True
24
+ self.current_row_data = {}
25
+ elif tag.lower() == 'input' and self.in_row and 'href' in attrs_dict:
26
+ href = attrs_dict.get('href')
27
+ if 'VerAntecedentes.aspx' in href or 'ViewAttachment.aspx' in href:
28
+ name = attrs_dict.get('value', 'Attachment')
29
+ self.attachments.append({'href': href, 'name': name})
30
+
31
+ def handle_endtag(self, tag):
32
+ if tag.lower() == 'tr':
33
+ self.in_row = False
34
+
35
+
36
+ async def extract_tender_detail_tabs(tender_code: str, qs_param: Optional[str] = None) -> Dict[str, Any]:
37
+ """
38
+ Fetch tender detail page and extract tab information.
39
+ Uses qs parameter if provided (encrypted detail URL).
40
+ Falls back to codigo parameter.
41
+ """
42
+ headers = {'User-Agent': 'Mozilla/5.0'}
43
+
44
+ if qs_param:
45
+ url = f"https://www.mercadopublico.cl/Procurement/Modules/RFB/DetailsAcquisition.aspx?qs={qs_param}"
46
+ else:
47
+ url = f"https://www.mercadopublico.cl/Procurement/Modules/RFB/DetailsAcquisition.aspx?codigo={tender_code}"
48
+
49
+ try:
50
+ async with httpx.AsyncClient(timeout=30.0) as client:
51
+ resp = await client.get(url, headers=headers)
52
+ if resp.status_code != 200:
53
+ return {"error": f"HTTP {resp.status_code}"}
54
+
55
+ html = resp.text
56
+ result = {
57
+ "tender_code": tender_code,
58
+ "url": str(resp.url),
59
+ "tabs": {},
60
+ "attachments": [],
61
+ "metadata": {}
62
+ }
63
+
64
+ # Extract attachments from grv* controls
65
+ extractor = AttachmentLinkExtractor()
66
+ extractor.feed(html)
67
+ result["attachments"] = extractor.attachments
68
+
69
+ # Extract tab sections (look for hidden controls that track tab state)
70
+ if 'imgHistorial' in html:
71
+ result["tabs"]["history"] = {"name": "Historial", "found": True}
72
+ if 'imgPreguntasLicitacion' in html:
73
+ result["tabs"]["questions"] = {"name": "Preguntas", "found": True}
74
+ if 'imgAperturaTecnica' in html:
75
+ result["tabs"]["opening"] = {"name": "Apertura", "found": True}
76
+
77
+ # Count attachment groups (Administrative, Technical, Economic)
78
+ result["metadata"]["has_administrative_docs"] = "grvAdministrativo" in html or html.count("Administrativo") > 0
79
+ result["metadata"]["has_technical_docs"] = "grvTecnico" in html or html.count("Técnico") > 0
80
+ result["metadata"]["has_economic_docs"] = "grvEconomico" in html or html.count("Económico") > 0
81
+
82
+ # Count questions/responses if mentioned
83
+ questions_match = re.search(r'Preguntas.*?(\d+)', html, re.IGNORECASE)
84
+ if questions_match:
85
+ result["metadata"]["question_count"] = int(questions_match.group(1))
86
+
87
+ # Extract adjudication info
88
+ if "adjudic" in html.lower():
89
+ result["metadata"]["has_adjudication"] = True
90
+
91
+ # Extract complaints and purchases (New Intelligence)
92
+ complaints_match = re.search(r'Reclamos.*?(\d+)', html, re.IGNORECASE)
93
+ if complaints_match:
94
+ result["metadata"]["buyer_complaints"] = int(complaints_match.group(1))
95
+
96
+ purchases_match = re.search(r'compras efectuadas.*?(\d+)', html, re.IGNORECASE)
97
+ if purchases_match:
98
+ result["metadata"]["buyer_purchases"] = int(purchases_match.group(1))
99
+
100
+ return result
101
+
102
+ except Exception as e:
103
+ return {"error": str(e), "tender_code": tender_code}
104
+
105
+
106
+ async def extract_all_attachments_for_tender(tender_code: str, qs_param: Optional[str] = None) -> List[Dict[str, str]]:
107
+ """
108
+ Extract all publicly accessible attachment URLs for a tender.
109
+ These can be used to download documents without authentication.
110
+ """
111
+ detail_info = await extract_tender_detail_tabs(tender_code, qs_param)
112
+ return detail_info.get("attachments", [])
frontend/app/page.tsx CHANGED
@@ -153,9 +153,9 @@ export default function HomePage() {
153
  }
154
  };
155
 
156
- const handleRunAnalysis = async (documentText?: string, models?: Record<string, string>) => {
157
  if (!selectedTender) return;
158
- const result = await analyzeTender(selectedTender, companyProfile, documentText, models);
159
  setAnalysisResult(result);
160
 
161
  try {
 
153
  }
154
  };
155
 
156
+ const handleRunAnalysis = async (documentText?: string, models?: Record<string, string>, tenderDetails?: any) => {
157
  if (!selectedTender) return;
158
+ const result = await analyzeTender(selectedTender, companyProfile, documentText, models, tenderDetails);
159
  setAnalysisResult(result);
160
 
161
  try {
frontend/components/AgentAnalysis.tsx CHANGED
@@ -1,15 +1,15 @@
1
  "use client";
2
 
3
  import { useState, useEffect } from "react";
4
- import type { AnalysisResult, CompanyProfile, Tender } from "../lib/types";
5
- import { uploadDocument } from "../lib/api";
6
  import AgentChat from "./AgentChat";
7
 
8
  type Props = {
9
  tender: Tender | null;
10
  companyProfile: CompanyProfile;
11
  analysis: AnalysisResult | null;
12
- onAnalyze: (documentText?: string, models?: Record<string, string>) => Promise<void>;
13
  onBackToSearch: () => void;
14
  };
15
 
@@ -33,6 +33,8 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
33
  const [activeSettings, setActiveSettings] = useState<string | null>(null);
34
  const [statusLog, setStatusLog] = useState<string[]>([]);
35
  const [error, setError] = useState<string | null>(null);
 
 
36
 
37
  // Multiple Files Support (The Corral)
38
  const [corral, setCorral] = useState<Array<{ file: File, text: string, analysis: AnalysisResult | null, id: string }>>([]);
@@ -41,6 +43,25 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
41
  const [isGeneratingAnnexes, setIsGeneratingAnnexes] = useState(false);
42
 
43
  // Removed auto-scroll to keep user at the top during demo recordings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  const generateAnnexes = async () => {
@@ -127,7 +148,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
127
  // We call the parent's onAnalyze but we want the result back locally too
128
  // Actually, since we want multiple analyses, we might need to handle the result here
129
  // For now, let's assume the parent updates the main analysis prop, but we'll store it in the corral too
130
- await onAnalyze(activeEntry.text, agentModels);
131
 
132
  clearInterval(progressTimer);
133
  setStatusLog(prev => [...prev, "✨ Analysis complete!"]);
@@ -218,6 +239,11 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
218
  {tender?.name.toLowerCase().includes('sustentable') || tender?.description?.toLowerCase().includes('ambiental') ? (
219
  <span className="rounded-full bg-green-500/20 px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-green-400 border border-green-500/30 animate-pulse">🌱 Sustainable / Compra Ágil</span>
220
  ) : null}
 
 
 
 
 
221
  <span className="text-xs text-slate-500 font-mono">{tender?.code}</span>
222
  </div>
223
  <h2 className="text-4xl font-bold text-white tracking-tight leading-tight mb-4">{tender?.name}</h2>
@@ -228,52 +254,76 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
228
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
229
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Investment</p>
230
  <p className="text-lg font-black text-white">
231
- {tender.estimated_amount ? new Intl.NumberFormat("es-CL", { style: "currency", currency: tender.currency || "CLP", maximumFractionDigits: 0 }).format(tender.estimated_amount) : "N/A"}
232
  </p>
233
  </div>
234
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
235
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Closing Date</p>
236
- <p className="text-lg font-black text-white">{tender.closing_date || "TBD"}</p>
237
  </div>
238
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
239
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Region</p>
240
- <p className="text-lg font-black text-white truncate" title={tender.region}>{tender.region || "Nacional"}</p>
241
  </div>
242
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
243
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Sector</p>
244
- <p className="text-lg font-black text-white truncate">{tender.sector || "General"}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  </div>
246
  </div>
247
  )}
248
 
249
  {tender?.description && (
250
  <div className="mt-8 p-6 rounded-2xl bg-white/[0.02] border border-white/5">
251
- <h4 className="text-[10px] font-bold uppercase text-slate-500 mb-3 tracking-[0.2em]">Detailed Scope</h4>
252
- <p className="text-sm text-slate-400 leading-relaxed max-h-32 overflow-y-auto custom-scrollbar pr-2 whitespace-pre-wrap">
253
  {tender.description}
254
  </p>
255
  </div>
256
  )}
257
 
258
  {tender?.items && tender.items.length > 0 && (
259
- <div className="mt-8 overflow-hidden rounded-2xl border border-white/5 bg-white/[0.01]">
260
- <table className="w-full text-left text-[10px]">
261
- <thead className="bg-white/5 text-slate-500 uppercase font-black tracking-widest">
262
  <tr>
263
- <th className="px-6 py-3">Item Name</th>
264
- <th className="px-6 py-3 text-right">Qty</th>
265
  </tr>
266
  </thead>
267
- <tbody className="divide-y divide-white/5">
268
  {tender.items.slice(0, 3).map((item, idx) => (
269
- <tr key={idx} className="hover:bg-white/[0.02]">
270
- <td className="px-6 py-3 text-slate-300 font-medium truncate max-w-[200px]">{item.name}</td>
271
- <td className="px-6 py-3 text-right text-cyan font-mono font-bold">{item.quantity} {item.unit}</td>
272
  </tr>
273
  ))}
274
  {tender.items.length > 3 && (
275
  <tr>
276
- <td colSpan={2} className="px-6 py-2 text-center text-[9px] text-slate-600 italic">
277
  + {tender.items.length - 3} more items...
278
  </td>
279
  </tr>
@@ -282,14 +332,76 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
282
  </table>
283
  </div>
284
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  </div>
286
 
287
- <div className="flex flex-col gap-4 lg:w-80">
288
- <div className="glass-card rounded-2xl p-6 bg-white/5 border border-white/10">
289
- <h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 tracking-widest">Document Corral</h4>
290
 
291
  {/* The Corral (Animal Pen) */}
292
- <div className="flex flex-wrap gap-3 mb-6">
293
  {corral.map((item) => {
294
  const icon = getFileIcon(item.file.name);
295
  return (
@@ -302,58 +414,58 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
302
  <span className={`text-2xl transition-all duration-500 group-hover:rotate-12 ${activeAnimalId === item.id ? 'animate-bounce' : 'group-hover:animate-wiggle'}`}>
303
  {icon.animal}
304
  </span>
305
- <span className="absolute -bottom-1 -right-1 text-[10px]">{icon.emoji}</span>
306
- {item.analysis && <span className="absolute -top-1 -right-1 h-3 w-3 bg-green-500 rounded-full border-2 border-black" title="Analyzed" />}
307
  </button>
308
  );
309
  })}
310
 
311
- <label className="flex flex-col items-center justify-center h-16 w-16 rounded-2xl border border-dashed border-white/20 bg-white/5 cursor-pointer hover:bg-white/10 hover:border-purple-500/50 transition-all">
312
- <span className="text-xl text-slate-500">+</span>
313
- <input type="file" onChange={handleFileChange} className="hidden" />
314
  </label>
315
  </div>
316
 
317
- <div className="text-[10px] text-slate-500 italic mb-4">
318
- {corral.length === 0 ? "No documents in the corral." : `${corral.length} document(s) ready.`}
319
  </div>
320
 
321
- {isUploading && <p className="text-[10px] text-purple-400 animate-pulse font-bold">✨ Bringing animal to corral...</p>}
322
  </div>
323
 
324
- <label className="flex items-center gap-3 p-4 rounded-2xl bg-white/5 cursor-pointer hover:bg-white/10 transition border border-white/5">
325
- <input type="checkbox" checked={approved} onChange={(e) => setApproved(e.target.checked)} className="h-5 w-5 rounded border-white/20 bg-black text-purple-500 outline-none accent-purple-500" />
326
- <span className="text-xs font-semibold text-slate-300">Authorize Agent War Room</span>
327
  </label>
328
 
329
  <button
330
  onClick={handleAnalyzeClick}
331
  disabled={!tender || !approved || isRunning || !activeAnimalId}
332
- className="w-full rounded-2xl premium-gradient py-5 font-bold text-white transition hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed shadow-xl shadow-purple-500/20 active:scale-[0.98]"
333
  >
334
- {isRunning ? "Agents Debating..." : "Launch Analysis Pipeline"}
335
  </button>
336
  </div>
337
  </div>
338
  </div>
339
 
340
  {/* Agents Row (Visual feedback & Configuration) */}
341
- <div className="grid gap-6 md:grid-cols-3">
342
  {agents.map((agent) => (
343
- <div key={agent.id} className="relative group">
344
  <div className={`glass-card rounded-3xl p-6 flex items-center gap-4 transition-all duration-700 ${isRunning ? 'ring-2 ring-purple-500/50 animate-pulse' : ''} ${analysis ? 'border-purple-500/30' : 'border-white/5'} hover:border-purple-500/20`}>
345
  <div className={`text-4xl ${isRunning ? 'animate-bounce' : ''}`}>{agent.avatar}</div>
346
- <div className="flex-1">
347
  <div className={`text-[10px] font-bold uppercase tracking-widest ${agent.color}`}>{agent.role}</div>
348
- <div className="text-sm font-bold text-white">{agent.name}</div>
349
- <div className="text-[9px] text-slate-500 font-mono mt-1 flex items-center gap-1">
350
- <span className="w-1 h-1 rounded-full bg-slate-500" />
351
  {agentModels[agent.id as keyof typeof agentModels]}
352
  </div>
353
  </div>
354
  <button
355
  onClick={() => setActiveSettings(activeSettings === agent.id ? null : agent.id)}
356
- className="p-2 rounded-xl bg-white/5 text-slate-500 hover:bg-white/10 hover:text-white transition-all active:scale-90"
357
  >
358
  ⚙️
359
  </button>
@@ -361,18 +473,18 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
361
 
362
  {/* Model Selector Popover */}
363
  {activeSettings === agent.id && (
364
- <div className="absolute top-full left-0 right-0 mt-2 z-50 glass-card rounded-2xl p-4 border border-purple-500/30 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
365
- <p className="text-[9px] font-black uppercase text-purple-400 mb-3 tracking-widest px-1">Select Engine</p>
366
- <div className="space-y-1">
367
  {[
368
- "Gemini 2.5 Flash",
369
- "DeepSeek-V3 (Featherless)",
370
- "Qwen-2.5 (Featherless)",
371
- "Llama-3.3-70B (Groq)",
372
- "Llama-3.1-8B (Groq)",
373
- "Mixtral-8x7B (Groq)",
374
- "Gemma-4-31B (Featherless)",
375
- "Llama-3.1-8B (Featherless)"
376
  ].map(model => (
377
  <button
378
  key={model}
@@ -383,7 +495,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
383
  className={`w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all flex items-center justify-between border ${agentModels[agent.id as keyof typeof agentModels] === model ? 'bg-purple-500/20 text-white border-purple-500/50 shadow-lg shadow-purple-500/10' : 'text-slate-400 border-transparent hover:bg-white/10 hover:text-white hover:border-white/10'}`}
384
  >
385
  <span>{model}</span>
386
- {agentModels[agent.id as keyof typeof agentModels] === model && <span className="text-purple-400">●</span>}
387
  </button>
388
  ))}
389
  </div>
@@ -395,16 +507,16 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
395
 
396
  {/* Running State Log */}
397
  {isRunning && (
398
- <div className="glass-card rounded-3xl p-8 border border-purple-500/30 bg-purple-500/5 animate-in fade-in zoom-in-95 duration-500">
399
- <div className="flex items-center gap-4 mb-6">
400
- <div className="h-4 w-4 rounded-full bg-purple-500 animate-ping" />
401
- <h3 className="text-xl font-bold text-white">Pipeline in Progress</h3>
402
  </div>
403
- <div className="space-y-3">
404
  {statusLog.map((log, i) => (
405
- <div key={i} className="flex items-center gap-3 animate-in slide-in-from-left-4 duration-300">
406
- <span className="text-purple-400 font-mono text-xs">[{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}]</span>
407
- <p className="text-sm text-slate-300">{log}</p>
408
  </div>
409
  ))}
410
  </div>
@@ -413,15 +525,15 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
413
 
414
  {/* Error State */}
415
  {error && (
416
- <div className="glass-card rounded-3xl p-8 border border-red-500/30 bg-red-500/5 animate-in fade-in zoom-in-95 duration-500">
417
- <div className="flex items-center gap-4 mb-4">
418
- <span className="text-3xl">⚠️</span>
419
- <h3 className="text-xl font-bold text-white">Analysis Failed</h3>
420
  </div>
421
- <p className="text-slate-400 mb-6">{error}</p>
422
  <button
423
  onClick={handleAnalyzeClick}
424
- className="px-6 py-3 rounded-2xl bg-red-500/20 text-red-400 font-bold border border-red-500/30 hover:bg-red-500/30 transition-all active:scale-95"
425
  >
426
  Retry Analysis
427
  </button>
@@ -430,26 +542,26 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
430
 
431
  {/* Analysis Results View */}
432
  {activeAnalysis && (
433
- <div id="analysis-results" className="grid gap-8 lg:grid-cols-12 animate-in fade-in slide-in-from-bottom-8 duration-500 scroll-mt-20">
434
- <div className="lg:col-span-8 space-y-8">
435
- <div className="glass-card rounded-3xl p-10 bg-white/[0.02]">
436
- <div className="flex items-start justify-between mb-8">
437
  <div>
438
- <div className="text-[11px] font-bold uppercase tracking-[0.3em] text-purple-400 mb-2">Agent Consensus</div>
439
- <h3 className="text-6xl font-black text-white">{activeAnalysis.fit_score}% <span className="text-2xl font-light text-slate-500">Fit Score</span></h3>
440
- <div className="mt-2 flex items-center gap-2">
441
- <span className="text-[10px] text-slate-500 font-mono">Analyzing:</span>
442
- <span className="text-[10px] text-purple-300 font-bold">{corral.find(a => a.id === activeAnimalId)?.file.name || tender?.name}</span>
443
  </div>
444
  </div>
445
- <div className="flex flex-col items-end gap-3">
446
  <div className={`rounded-2xl px-6 py-3 text-[10px] font-black uppercase tracking-widest shadow-lg ${activeAnalysis.decision === 'Recommended' ? 'bg-green-500/20 text-green-400 border border-green-500/30 shadow-green-500/10' : 'bg-amber-500/20 text-amber-400 border border-amber-500/30 shadow-amber-500/10'}`}>
447
  {activeAnalysis.decision}
448
  </div>
449
- <div className="flex gap-2">
450
  <button
451
  onClick={() => window.print()}
452
- className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400 hover:text-white hover:bg-white/10 transition uppercase tracking-[0.2em]"
453
  >
454
  Export PDF
455
  </button>
@@ -461,36 +573,36 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
461
  {isGeneratingAnnexes ? 'Generating...' : '✨ Anexos Express'}
462
  </button>
463
  <button
464
- onClick={() => alert("Report sent to executive committee via REW Secure Channel.")}
465
- className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-slate-400 hover:text-white hover:bg-white/10 transition"
466
- title="Share Analysis"
467
  >
468
  📧
469
  </button>
470
  </div>
471
  </div>
472
  </div>
473
- <div className="prose prose-invert max-w-none">
474
- <p className="text-slate-300 text-xl leading-relaxed italic border-l-4 border-purple-500 pl-8">{activeAnalysis.executive_summary}</p>
475
  </div>
476
 
477
  {/* Requirement Q&A Section */}
478
  {activeAnalysis.requirement_responses && activeAnalysis.requirement_responses.length > 0 && (
479
- <div className="mt-12 space-y-6">
480
- <div className="flex items-center gap-3 border-b border-white/5 pb-4">
481
- <span className="text-2xl">📋</span>
482
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Requirement Response (Q&A Style)</h4>
483
  </div>
484
- <div className="grid gap-4">
485
  {activeAnalysis.requirement_responses.map((item, i) => (
486
- <div key={i} className="rounded-2xl bg-white/[0.03] border border-white/5 p-6 hover:border-purple-500/30 transition-all group">
487
- <div className="flex gap-4">
488
- <span className="text-purple-500 font-bold font-mono">Q.</span>
489
- <p className="text-white font-semibold text-sm">{item.question}</p>
490
  </div>
491
- <div className="mt-4 flex gap-4 pl-8 border-l border-white/10">
492
- <span className="text-green-400 font-bold font-mono">A.</span>
493
- <p className="text-slate-400 text-sm leading-relaxed">{item.answer}</p>
494
  </div>
495
  </div>
496
  ))}
@@ -500,20 +612,20 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
500
 
501
  {/* Proposal Draft Section */}
502
  {activeAnalysis.proposal_draft && (
503
- <div className="mt-12 space-y-6">
504
- <div className="flex items-center justify-between border-b border-white/5 pb-4">
505
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-purple-400">AI Generated Proposal Draft</h4>
506
  <button
507
  onClick={() => {
508
  navigator.clipboard.writeText(activeAnalysis.proposal_draft);
509
- alert("Proposal copied to clipboard!");
510
  }}
511
- className="text-[10px] font-bold uppercase text-slate-500 hover:text-white transition"
512
  >
513
  Copy to Clipboard 📋
514
  </button>
515
  </div>
516
- <div className="p-8 rounded-3xl bg-white/[0.03] border border-white/10 font-serif text-slate-400 text-sm leading-relaxed whitespace-pre-wrap max-h-[500px] overflow-y-auto custom-scrollbar">
517
  {activeAnalysis.proposal_draft}
518
  </div>
519
  </div>
@@ -521,54 +633,54 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
521
  </div>
522
 
523
 
524
- <div className="grid gap-6 md:grid-cols-2">
525
- <div className="glass-card rounded-3xl p-8 bg-white/[0.01]">
526
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-amber-400 mb-6 flex items-center gap-2">
527
  <span>⚠️</span> Legal Compliance Gaps
528
  </h4>
529
- <ul className="space-y-4">
530
  {activeAnalysis.compliance_gaps.map((gap, i) => (
531
- <li key={i} className="flex gap-4 text-sm text-slate-400 leading-relaxed">
532
- <span className="text-amber-500 font-bold">•</span> {gap}
533
  </li>
534
  ))}
535
  </ul>
536
  </div>
537
- <div className="glass-card rounded-3xl p-8 bg-white/[0.01]">
538
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-cyan mb-6 flex items-center gap-2">
539
  <span>💎</span> Technical Requirements
540
  </h4>
541
- <ul className="space-y-4">
542
  {activeAnalysis.key_requirements.map((req, i) => (
543
- <li key={i} className="flex gap-4 text-sm text-slate-400 leading-relaxed">
544
- <span className="text-cyan font-bold">▹</span> {req}
545
  </li>
546
  ))}
547
  </ul>
548
  </div>
549
  </div>
550
 
551
- <div className="glass-card rounded-3xl p-10 bg-white/[0.01]">
552
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-purple-400 mb-8 text-center">Neural Risk Matrix</h4>
553
- <div className="grid gap-6 md:grid-cols-2 mb-12">
554
  {activeAnalysis.risks.map((risk, i) => (
555
- <div key={i} className="group rounded-3xl bg-white/[0.02] p-6 border border-white/5 hover:border-purple-500/30 transition-all duration-300">
556
- <div className="flex items-center justify-between mb-4">
557
- <span className="font-bold text-white text-lg group-hover:text-purple-400 transition">{risk.title}</span>
558
  <span className={`text-[9px] font-black px-3 py-1 rounded-full uppercase tracking-widest ${risk.severity === 'High' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-white/5 text-slate-500 border border-white/5'}`}>{risk.severity}</span>
559
  </div>
560
- <p className="text-xs text-slate-500 leading-relaxed">{risk.explanation}</p>
561
  </div>
562
  ))}
563
  </div>
564
 
565
  {activeAnalysis.strategic_roadmap && (
566
- <div className="mt-8 pt-8 border-t border-white/5">
567
- <h4 className="text-[11px] font-bold uppercase tracking-widest text-cyan mb-6 text-center">Winning Strategic Roadmap</h4>
568
- <div className="p-8 rounded-3xl bg-cyan/5 border border-cyan/20 text-sm text-slate-300 leading-relaxed italic">
569
- <div className="prose prose-invert prose-sm max-w-none">
570
  {activeAnalysis.strategic_roadmap.split('\n').map((line, i) => (
571
- <p key={i} className="mb-2">{line}</p>
572
  ))}
573
  </div>
574
  </div>
@@ -578,21 +690,21 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
578
 
579
  </div>
580
 
581
- <div className="lg:col-span-4">
582
- <div className="glass-card rounded-3xl p-8 bg-black/40 h-full sticky top-32">
583
- <div className="flex items-center gap-3 mb-8 border-b border-white/5 pb-6">
584
- <div className="h-2 w-2 rounded-full bg-purple-500 animate-pulse shadow-[0_0_12px_rgba(168,85,247,0.8)]" />
585
- <h4 className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Agent Intelligence Log</h4>
586
  </div>
587
- <div className="space-y-8 overflow-y-auto max-h-[700px] pr-2 custom-scrollbar">
588
  {activeAnalysis.audit_log?.map((log, i) => (
589
- <div key={i} className="flex gap-5 group">
590
- <div className="flex flex-col items-center">
591
- <div className="h-8 w-8 rounded-xl bg-white/5 flex items-center justify-center text-sm border border-white/10 group-hover:border-purple-500/50 transition-all duration-300 shadow-lg">🤖</div>
592
- {i < (activeAnalysis.audit_log?.length ?? 0) - 1 && <div className="w-px flex-1 bg-gradient-to-b from-purple-500/40 to-transparent my-3" />}
593
  </div>
594
- <div className="pb-6">
595
- <p className="text-[13px] text-slate-400 leading-relaxed group-hover:text-white transition-colors duration-300">{log}</p>
596
  </div>
597
  </div>
598
  ))}
@@ -601,23 +713,23 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
601
 
602
  {/* Anexos Express Section */}
603
  {generatedAnnexes.length > 0 && (
604
- <div id="annexes-section" className="mt-8 glass-card rounded-3xl p-10 bg-purple-500/[0.03] border border-purple-500/20 animate-in fade-in slide-in-from-bottom-8 duration-700">
605
- <div className="flex items-center gap-4 mb-8">
606
- <div className="w-12 h-12 rounded-2xl bg-purple-500/20 flex items-center justify-center text-2xl shadow-lg shadow-purple-500/20">📄</div>
607
  <div>
608
- <h4 className="text-2xl font-black text-white tracking-tight">Compliance: Anexos Express</h4>
609
- <p className="text-slate-500 text-sm">Official annexes pre-filled with company data and tender requirements.</p>
610
  </div>
611
  </div>
612
 
613
- <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-3">
614
  {generatedAnnexes.map((annex, i) => (
615
- <div key={i} className="group rounded-3xl bg-white/[0.02] border border-white/5 p-6 hover:border-purple-500/40 transition-all">
616
- <div className="text-[10px] font-bold uppercase text-purple-400 mb-3 tracking-widest">Template Generated</div>
617
- <h5 className="text-white font-bold mb-4 line-clamp-1">{annex.name}</h5>
618
- <div className="bg-black/40 rounded-xl p-4 text-[9px] font-mono text-slate-500 mb-4 h-32 overflow-hidden relative">
619
- <pre className="whitespace-pre-wrap">{annex.content}</pre>
620
- <div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/60 to-transparent" />
621
  </div>
622
  <button
623
  onClick={() => {
@@ -628,7 +740,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
628
  a.download = `${annex.name.replace(/ /g, '_')}.md`;
629
  a.click();
630
  }}
631
- className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400 hover:text-white hover:bg-white/10 transition uppercase tracking-widest"
632
  >
633
  Download .md 📥
634
  </button>
@@ -643,12 +755,12 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
643
 
644
  {/* Expert Consultation Chat */}
645
  {tender && (
646
- <div className="mt-12 animate-in fade-in slide-in-from-bottom-8 duration-700">
647
- <div className="flex items-center gap-4 mb-6 px-2">
648
- <div className="w-10 h-10 rounded-2xl bg-purple-500/10 flex items-center justify-center text-xl shadow-lg shadow-purple-500/10">💬</div>
649
  <div>
650
- <h3 className="text-2xl font-black text-white tracking-tight">Expert Agent Consultation</h3>
651
- <p className="text-slate-500 text-sm">Deep-dive into specific questions with our specialized AI agents.</p>
652
  </div>
653
  </div>
654
  <AgentChat tender={tender} companyProfile={companyProfile} />
 
1
  "use client";
2
 
3
  import { useState, useEffect } from "react";
4
+ import type { AnalysisResult, CompanyProfile, Tender, TenderDetailInfo } from "../lib/types";
5
+ import { uploadDocument, fetchTenderDetails } from "../lib/api";
6
  import AgentChat from "./AgentChat";
7
 
8
  type Props = {
9
  tender: Tender | null;
10
  companyProfile: CompanyProfile;
11
  analysis: AnalysisResult | null;
12
+ onAnalyze: (documentText?: string, models?: Record<string, string>, tenderDetails?: TenderDetailInfo | null) => Promise<void>;
13
  onBackToSearch: () => void;
14
  };
15
 
 
33
  const [activeSettings, setActiveSettings] = useState<string | null>(null);
34
  const [statusLog, setStatusLog] = useState<string[]>([]);
35
  const [error, setError] = useState<string | null>(null);
36
+ const [tenderDetails, setTenderDetails] = useState<TenderDetailInfo | null>(null);
37
+ const [isLoadingDetails, setIsLoadingDetails] = useState(false);
38
 
39
  // Multiple Files Support (The Corral)
40
  const [corral, setCorral] = useState<Array<{ file: File, text: string, analysis: AnalysisResult | null, id: string }>>([]);
 
43
  const [isGeneratingAnnexes, setIsGeneratingAnnexes] = useState(false);
44
 
45
  // Removed auto-scroll to keep user at the top during demo recordings
46
+
47
+ // Fetch Tender Details (Scraped)
48
+ useEffect(() => {
49
+ const getDetails = async () => {
50
+ if (!tender?.code) return;
51
+ setIsLoadingDetails(true);
52
+ try {
53
+ // Try to get details using both code and potential qs (if available in tender object)
54
+ // Note: For now we use code, if the API returns a qs param we should use it
55
+ const details = await fetchTenderDetails(tender.code);
56
+ setTenderDetails(details);
57
+ } catch (err) {
58
+ console.error("Failed to fetch tender details:", err);
59
+ } finally {
60
+ setIsLoadingDetails(false);
61
+ }
62
+ };
63
+ getDetails();
64
+ }, [tender?.code]);
65
 
66
 
67
  const generateAnnexes = async () => {
 
148
  // We call the parent's onAnalyze but we want the result back locally too
149
  // Actually, since we want multiple analyses, we might need to handle the result here
150
  // For now, let's assume the parent updates the main analysis prop, but we'll store it in the corral too
151
+ await onAnalyze(activeEntry.text, agentModels, tenderDetails);
152
 
153
  clearInterval(progressTimer);
154
  setStatusLog(prev => [...prev, "✨ Analysis complete!"]);
 
239
  {tender?.name.toLowerCase().includes('sustentable') || tender?.description?.toLowerCase().includes('ambiental') ? (
240
  <span className="rounded-full bg-green-500/20 px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-green-400 border border-green-500/30 animate-pulse">🌱 Sustainable / Compra Ágil</span>
241
  ) : null}
242
+ {tenderDetails?.metadata?.question_count && tenderDetails.metadata.question_count > 0 ? (
243
+ <span className="rounded-full bg-cyan/20 px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-cyan border border-cyan/30">
244
+ 💬 {tenderDetails.metadata.question_count} Questions
245
+ </span>
246
+ ) : null}
247
  <span className="text-xs text-slate-500 font-mono">{tender?.code}</span>
248
  </div>
249
  <h2 className="text-4xl font-bold text-white tracking-tight leading-tight mb-4">{tender?.name}</h2>
 
254
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
255
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Investment</p>
256
  <p className="text-lg font-black text-white">
257
+ {tender.estimated_amount ? new Intl.NumberFormat(\"es-CL\", { style: \"currency\", currency: tender.currency || \"CLP\", maximumFractionDigits: 0 }).format(tender.estimated_amount) : \"N/A\"}
258
  </p>
259
  </div>
260
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
261
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Closing Date</p>
262
+ <p className="text-lg font-black text-white">{tender.closing_date || \"TBD\"}</p>
263
  </div>
264
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
265
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Region</p>
266
+ <p className="text-lg font-black text-white truncate" title={tender.region}>{tender.region || \"Nacional\"}</p>
267
  </div>
268
  <div className="rounded-2xl bg-white/5 p-4 border border-white/5 group hover:bg-white/10 transition-colors">
269
  <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Sector</p>
270
+ <p className="text-lg font-black text-white truncate">{tender.sector || \"General\"}</p>
271
+ </div>
272
+ </div>
273
+ )}
274
+
275
+ {/* Buyer Risk & Experience Cards */}
276
+ {(tender?.buyer_complaints !== undefined || tender?.buyer_purchases !== undefined || tenderDetails?.metadata?.buyer_complaints !== undefined) && (
277
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
278
+ <div className={`rounded-2xl p-4 border flex items-center justify-between transition-all ${(tender?.buyer_complaints ?? tenderDetails?.metadata?.buyer_complaints ?? 0) > 10 ? 'bg-red-500/10 border-red-500/30 shadow-lg shadow-red-500/10' : 'bg-white/5 border-white/5'}`}>
279
+ <div>
280
+ <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Complaints (Last 12m)</p>
281
+ <p className={`text-2xl font-black ${(tender?.buyer_complaints ?? tenderDetails?.metadata?.buyer_complaints ?? 0) > 10 ? 'text-red-400' : 'text-white'}`}>
282
+ {tender?.buyer_complaints ?? tenderDetails?.metadata?.buyer_complaints ?? 0}
283
+ </p>
284
+ </div>
285
+ <div className="text-2xl opacity-50">{(tender?.buyer_complaints ?? tenderDetails?.metadata?.buyer_complaints ?? 0) > 10 ? '⚠️' : '✅'}</div>
286
+ </div>
287
+ <div className="rounded-2xl bg-cyan/5 p-4 border border-cyan/10 flex items-center justify-between transition-all hover:bg-cyan/10">
288
+ <div>
289
+ <p className="text-[10px] uppercase text-slate-500 font-bold mb-1 tracking-widest">Purchases Executed</p>
290
+ <p className="text-2xl font-black text-cyan">
291
+ {tender?.buyer_purchases ?? tenderDetails?.metadata?.buyer_purchases ?? \"1.6k+\"}
292
+ </p>
293
+ </div>
294
+ <div className="text-2xl opacity-50">🛒</div>
295
  </div>
296
  </div>
297
  )}
298
 
299
  {tender?.description && (
300
  <div className="mt-8 p-6 rounded-2xl bg-white/[0.02] border border-white/5">
301
+ <h4 className=\"text-[10px] font-bold uppercase text-slate-500 mb-3 tracking-[0.2em]\">Detailed Scope</h4>
302
+ <p className=\"text-sm text-slate-400 leading-relaxed max-h-32 overflow-y-auto custom-scrollbar pr-2 whitespace-pre-wrap\">
303
  {tender.description}
304
  </p>
305
  </div>
306
  )}
307
 
308
  {tender?.items && tender.items.length > 0 && (
309
+ <div className=\"mt-8 overflow-hidden rounded-2xl border border-white/5 bg-white/[0.01]\">
310
+ <table className=\"w-full text-left text-[10px]\">
311
+ <thead className=\"bg-white/5 text-slate-500 uppercase font-black tracking-widest\">
312
  <tr>
313
+ <th className=\"px-6 py-3\">Item Name</th>
314
+ <th className=\"px-6 py-3 text-right\">Qty</th>
315
  </tr>
316
  </thead>
317
+ <tbody className=\"divide-y divide-white/5\">
318
  {tender.items.slice(0, 3).map((item, idx) => (
319
+ <tr key={idx} className=\"hover:bg-white/[0.02]\">
320
+ <td className=\"px-6 py-3 text-slate-300 font-medium truncate max-w-[200px]\">{item.name}</td>
321
+ <td className=\"px-6 py-3 text-right text-cyan font-mono font-bold\">{item.quantity} {item.unit}</td>
322
  </tr>
323
  ))}
324
  {tender.items.length > 3 && (
325
  <tr>
326
+ <td colSpan={2} className=\"px-6 py-2 text-center text-[9px] text-slate-600 italic\">
327
  + {tender.items.length - 3} more items...
328
  </td>
329
  </tr>
 
332
  </table>
333
  </div>
334
  )}
335
+
336
+ {/* Scraped Intelligence / Tabs */}
337
+ {tenderDetails && (
338
+ <div className=\"mt-8 flex flex-wrap gap-4\">
339
+ {tenderDetails.tabs?.history?.found && (
340
+ <div className=\"flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400\">
341
+ <span className=\"text-purple-400 text-xs\">📜</span> History Available
342
+ </div>
343
+ )}
344
+ {tenderDetails.tabs?.questions?.found && (
345
+ <div className=\"flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400\">
346
+ <span className=\"text-cyan text-xs\">❓</span> Q&A Active
347
+ </div>
348
+ )}
349
+ {tenderDetails.tabs?.opening?.found && (
350
+ <div className=\"flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400\">
351
+ <span className=\"text-green-400 text-xs\">🔓</span> Opening Log Found
352
+ </div>
353
+ )}
354
+ {tenderDetails.metadata?.has_adjudication && (
355
+ <div className=\"flex items-center gap-2 px-4 py-2 rounded-xl bg-green-500/10 border border-green-500/20 text-[10px] font-bold text-green-400\">
356
+ <span className=\"text-xs\">🏆</span> Adjudicated
357
+ </div>
358
+ )}
359
+ </div>
360
+ )}
361
+
362
+ {/* Scraped Attachments (Extended List) */}
363
+ {tenderDetails?.attachments && tenderDetails.attachments.length > 0 && (
364
+ <div className=\"mt-8 space-y-4\">
365
+ <div className=\"flex items-center justify-between\">
366
+ <h4 className=\"text-[10px] font-bold uppercase text-slate-500 tracking-[0.2em]\">Scraped Attachments ({tenderDetails.attachments.length})</h4>
367
+ {isLoadingDetails && <span className=\"text-[9px] text-purple-400 animate-pulse uppercase font-black\">Refreshing...</span>}
368
+ </div>
369
+ <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-3\">
370
+ {tenderDetails.attachments.slice(0, 6).map((att, idx) => (
371
+ <a
372
+ key={idx}
373
+ href={att.url}
374
+ target=\"_blank\"
375
+ rel=\"noopener noreferrer\"
376
+ className=\"flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] border border-white/5 hover:bg-white/10 hover:border-purple-500/30 transition-all group\"
377
+ >
378
+ <span className=\"text-lg group-hover:scale-110 transition-transform\">
379
+ {att.name.toLowerCase().includes('bases') ? '⚖️' :
380
+ att.name.toLowerCase().includes('tecnico') ? '🛠️' :
381
+ att.name.toLowerCase().includes('anexo') ? '📝' : '📄'}
382
+ </span>
383
+ <div className=\"flex-1 min-w-0\">
384
+ <p className=\"text-[11px] font-bold text-slate-300 truncate group-hover:text-white\">{att.name}</p>
385
+ <p className=\"text-[9px] text-slate-500 uppercase tracking-tighter\">Direct Download 📥</p>
386
+ </div>
387
+ </a>
388
+ ))}
389
+ {tenderDetails.attachments.length > 6 && (
390
+ <div className=\"flex items-center justify-center p-3 rounded-xl border border-dashed border-white/10 text-[9px] text-slate-600 uppercase font-bold\">
391
+ + {tenderDetails.attachments.length - 6} more attachments
392
+ </div>
393
+ )}
394
+ </div>
395
+ </div>
396
+ )}
397
  </div>
398
 
399
+ <div className=\"flex flex-col gap-4 lg:w-80\">
400
+ <div className=\"glass-card rounded-2xl p-6 bg-white/5 border border-white/10\">
401
+ <h4 className=\"text-[10px] font-bold uppercase text-slate-400 mb-4 tracking-widest\">Document Corral</h4>
402
 
403
  {/* The Corral (Animal Pen) */}
404
+ <div className=\"flex flex-wrap gap-3 mb-6\">
405
  {corral.map((item) => {
406
  const icon = getFileIcon(item.file.name);
407
  return (
 
414
  <span className={`text-2xl transition-all duration-500 group-hover:rotate-12 ${activeAnimalId === item.id ? 'animate-bounce' : 'group-hover:animate-wiggle'}`}>
415
  {icon.animal}
416
  </span>
417
+ <span className=\"absolute -bottom-1 -right-1 text-[10px]\">{icon.emoji}</span>
418
+ {item.analysis && <span className=\"absolute -top-1 -right-1 h-3 w-3 bg-green-500 rounded-full border-2 border-black\" title=\"Analyzed\" />}
419
  </button>
420
  );
421
  })}
422
 
423
+ <label className=\"flex flex-col items-center justify-center h-16 w-16 rounded-2xl border border-dashed border-white/20 bg-white/5 cursor-pointer hover:bg-white/10 hover:border-purple-500/50 transition-all\">
424
+ <span className=\"text-xl text-slate-500\">+</span>
425
+ <input type=\"file\" onChange={handleFileChange} className=\"hidden\" />
426
  </label>
427
  </div>
428
 
429
+ <div className=\"text-[10px] text-slate-500 italic mb-4\">
430
+ {corral.length === 0 ? \"No documents in the corral.\" : `${corral.length} document(s) ready.`}
431
  </div>
432
 
433
+ {isUploading && <p className=\"text-[10px] text-purple-400 animate-pulse font-bold\">✨ Bringing animal to corral...</p>}
434
  </div>
435
 
436
+ <label className=\"flex items-center gap-3 p-4 rounded-2xl bg-white/5 cursor-pointer hover:bg-white/10 transition border border-white/5\">
437
+ <input type=\"checkbox\" checked={approved} onChange={(e) => setApproved(e.target.checked)} className=\"h-5 w-5 rounded border-white/20 bg-black text-purple-500 outline-none accent-purple-500\" />
438
+ <span className=\"text-xs font-semibold text-slate-300\">Authorize Agent War Room</span>
439
  </label>
440
 
441
  <button
442
  onClick={handleAnalyzeClick}
443
  disabled={!tender || !approved || isRunning || !activeAnimalId}
444
+ className=\"w-full rounded-2xl premium-gradient py-5 font-bold text-white transition hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed shadow-xl shadow-purple-500/20 active:scale-[0.98]\"
445
  >
446
+ {isRunning ? \"Agents Debating...\" : \"Launch Analysis Pipeline\"}
447
  </button>
448
  </div>
449
  </div>
450
  </div>
451
 
452
  {/* Agents Row (Visual feedback & Configuration) */}
453
+ <div className=\"grid gap-6 md:grid-cols-3\">
454
  {agents.map((agent) => (
455
+ <div key={agent.id} className=\"relative group\">
456
  <div className={`glass-card rounded-3xl p-6 flex items-center gap-4 transition-all duration-700 ${isRunning ? 'ring-2 ring-purple-500/50 animate-pulse' : ''} ${analysis ? 'border-purple-500/30' : 'border-white/5'} hover:border-purple-500/20`}>
457
  <div className={`text-4xl ${isRunning ? 'animate-bounce' : ''}`}>{agent.avatar}</div>
458
+ <div className=\"flex-1\">
459
  <div className={`text-[10px] font-bold uppercase tracking-widest ${agent.color}`}>{agent.role}</div>
460
+ <div className=\"text-sm font-bold text-white\">{agent.name}</div>
461
+ <div className=\"text-[9px] text-slate-500 font-mono mt-1 flex items-center gap-1\">
462
+ <span className=\"w-1 h-1 rounded-full bg-slate-500\" />
463
  {agentModels[agent.id as keyof typeof agentModels]}
464
  </div>
465
  </div>
466
  <button
467
  onClick={() => setActiveSettings(activeSettings === agent.id ? null : agent.id)}
468
+ className=\"p-2 rounded-xl bg-white/5 text-slate-500 hover:bg-white/10 hover:text-white transition-all active:scale-90\"
469
  >
470
  ⚙️
471
  </button>
 
473
 
474
  {/* Model Selector Popover */}
475
  {activeSettings === agent.id && (
476
+ <div className=\"absolute top-full left-0 right-0 mt-2 z-50 glass-card rounded-2xl p-4 border border-purple-500/30 shadow-2xl animate-in fade-in zoom-in-95 duration-200\">
477
+ <p className=\"text-[9px] font-black uppercase text-purple-400 mb-3 tracking-widest px-1\">Select Engine</p>
478
+ <div className=\"space-y-1\">
479
  {[
480
+ \"Gemini 2.5 Flash\",
481
+ \"DeepSeek-V3 (Featherless)\",
482
+ \"Qwen-2.5 (Featherless)\",
483
+ \"Llama-3.3-70B (Groq)\",
484
+ \"Llama-3.1-8B (Groq)\",
485
+ \"Mixtral-8x7B (Groq)\",
486
+ \"Gemma-4-31B (Featherless)\",
487
+ \"Llama-3.1-8B (Featherless)\"
488
  ].map(model => (
489
  <button
490
  key={model}
 
495
  className={`w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all flex items-center justify-between border ${agentModels[agent.id as keyof typeof agentModels] === model ? 'bg-purple-500/20 text-white border-purple-500/50 shadow-lg shadow-purple-500/10' : 'text-slate-400 border-transparent hover:bg-white/10 hover:text-white hover:border-white/10'}`}
496
  >
497
  <span>{model}</span>
498
+ {agentModels[agent.id as keyof typeof agentModels] === model && <span className=\"text-purple-400\">●</span>}
499
  </button>
500
  ))}
501
  </div>
 
507
 
508
  {/* Running State Log */}
509
  {isRunning && (
510
+ <div className=\"glass-card rounded-3xl p-8 border border-purple-500/30 bg-purple-500/5 animate-in fade-in zoom-in-95 duration-500\">
511
+ <div className=\"flex items-center gap-4 mb-6\">
512
+ <div className=\"h-4 w-4 rounded-full bg-purple-500 animate-ping\" />
513
+ <h3 className=\"text-xl font-bold text-white\">Pipeline in Progress</h3>
514
  </div>
515
+ <div className=\"space-y-3\">
516
  {statusLog.map((log, i) => (
517
+ <div key={i} className=\"flex items-center gap-3 animate-in slide-in-from-left-4 duration-300\">
518
+ <span className=\"text-purple-400 font-mono text-xs\">[{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}]</span>
519
+ <p className=\"text-sm text-slate-300\">{log}</p>
520
  </div>
521
  ))}
522
  </div>
 
525
 
526
  {/* Error State */}
527
  {error && (
528
+ <div className=\"glass-card rounded-3xl p-8 border border-red-500/30 bg-red-500/5 animate-in fade-in zoom-in-95 duration-500\">
529
+ <div className=\"flex items-center gap-4 mb-4\">
530
+ <span className=\"text-3xl\">⚠️</span>
531
+ <h3 className=\"text-xl font-bold text-white\">Analysis Failed</h3>
532
  </div>
533
+ <p className=\"text-slate-400 mb-6\">{error}</p>
534
  <button
535
  onClick={handleAnalyzeClick}
536
+ className=\"px-6 py-3 rounded-2xl bg-red-500/20 text-red-400 font-bold border border-red-500/30 hover:bg-red-500/30 transition-all active:scale-95\"
537
  >
538
  Retry Analysis
539
  </button>
 
542
 
543
  {/* Analysis Results View */}
544
  {activeAnalysis && (
545
+ <div id=\"analysis-results\" className=\"grid gap-8 lg:grid-cols-12 animate-in fade-in slide-in-from-bottom-8 duration-500 scroll-mt-20\">
546
+ <div className=\"lg:col-span-8 space-y-8\">
547
+ <div className=\"glass-card rounded-3xl p-10 bg-white/[0.02]\">
548
+ <div className=\"flex items-start justify-between mb-8\">
549
  <div>
550
+ <div className=\"text-[11px] font-bold uppercase tracking-[0.3em] text-purple-400 mb-2\">Agent Consensus</div>
551
+ <h3 className=\"text-6xl font-black text-white\">{activeAnalysis.fit_score}% <span className=\"text-2xl font-light text-slate-500\">Fit Score</span></h3>
552
+ <div className=\"mt-2 flex items-center gap-2\">
553
+ <span className=\"text-[10px] text-slate-500 font-mono\">Analyzing:</span>
554
+ <span className=\"text-[10px] text-purple-300 font-bold\">{corral.find(a => a.id === activeAnimalId)?.file.name || tender?.name}</span>
555
  </div>
556
  </div>
557
+ <div className=\"flex flex-col items-end gap-3\">
558
  <div className={`rounded-2xl px-6 py-3 text-[10px] font-black uppercase tracking-widest shadow-lg ${activeAnalysis.decision === 'Recommended' ? 'bg-green-500/20 text-green-400 border border-green-500/30 shadow-green-500/10' : 'bg-amber-500/20 text-amber-400 border border-amber-500/30 shadow-amber-500/10'}`}>
559
  {activeAnalysis.decision}
560
  </div>
561
+ <div className=\"flex gap-2\">
562
  <button
563
  onClick={() => window.print()}
564
+ className=\"px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400 hover:text-white hover:bg-white/10 transition uppercase tracking-[0.2em]\"
565
  >
566
  Export PDF
567
  </button>
 
573
  {isGeneratingAnnexes ? 'Generating...' : '✨ Anexos Express'}
574
  </button>
575
  <button
576
+ onClick={() => alert(\"Report sent to executive committee via REW Secure Channel.\")}
577
+ className=\"px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-slate-400 hover:text-white hover:bg-white/10 transition\"
578
+ title=\"Share Analysis\"
579
  >
580
  📧
581
  </button>
582
  </div>
583
  </div>
584
  </div>
585
+ <div className=\"prose prose-invert max-w-none\">
586
+ <p className=\"text-slate-300 text-xl leading-relaxed italic border-l-4 border-purple-500 pl-8\">{activeAnalysis.executive_summary}</p>
587
  </div>
588
 
589
  {/* Requirement Q&A Section */}
590
  {activeAnalysis.requirement_responses && activeAnalysis.requirement_responses.length > 0 && (
591
+ <div className=\"mt-12 space-y-6\">
592
+ <div className=\"flex items-center gap-3 border-b border-white/5 pb-4\">
593
+ <span className=\"text-2xl\">📋</span>
594
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-purple-400\">Requirement Response (Q&A Style)</h4>
595
  </div>
596
+ <div className=\"grid gap-4\">
597
  {activeAnalysis.requirement_responses.map((item, i) => (
598
+ <div key={i} className=\"rounded-2xl bg-white/[0.03] border border-white/5 p-6 hover:border-purple-500/30 transition-all group\">
599
+ <div className=\"flex gap-4\">
600
+ <span className=\"text-purple-500 font-bold font-mono\">Q.</span>
601
+ <p className=\"text-white font-semibold text-sm\">{item.question}</p>
602
  </div>
603
+ <div className=\"mt-4 flex gap-4 pl-8 border-l border-white/10\">
604
+ <span className=\"text-green-400 font-bold font-mono\">A.</span>
605
+ <p className=\"text-slate-400 text-sm leading-relaxed\">{item.answer}</p>
606
  </div>
607
  </div>
608
  ))}
 
612
 
613
  {/* Proposal Draft Section */}
614
  {activeAnalysis.proposal_draft && (
615
+ <div className=\"mt-12 space-y-6\">
616
+ <div className=\"flex items-center justify-between border-b border-white/5 pb-4\">
617
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-purple-400\">AI Generated Proposal Draft</h4>
618
  <button
619
  onClick={() => {
620
  navigator.clipboard.writeText(activeAnalysis.proposal_draft);
621
+ alert(\"Proposal copied to clipboard!\");
622
  }}
623
+ className=\"text-[10px] font-bold uppercase text-slate-500 hover:text-white transition\"
624
  >
625
  Copy to Clipboard 📋
626
  </button>
627
  </div>
628
+ <div className=\"p-8 rounded-3xl bg-white/[0.03] border border-white/10 font-serif text-slate-400 text-sm leading-relaxed whitespace-pre-wrap max-h-[500px] overflow-y-auto custom-scrollbar\">
629
  {activeAnalysis.proposal_draft}
630
  </div>
631
  </div>
 
633
  </div>
634
 
635
 
636
+ <div className=\"grid gap-6 md:grid-cols-2\">
637
+ <div className=\"glass-card rounded-3xl p-8 bg-white/[0.01]\">
638
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-amber-400 mb-6 flex items-center gap-2\">
639
  <span>⚠️</span> Legal Compliance Gaps
640
  </h4>
641
+ <ul className=\"space-y-4\">
642
  {activeAnalysis.compliance_gaps.map((gap, i) => (
643
+ <li key={i} className=\"flex gap-4 text-sm text-slate-400 leading-relaxed\">
644
+ <span className=\"text-amber-500 font-bold\">•</span> {gap}
645
  </li>
646
  ))}
647
  </ul>
648
  </div>
649
+ <div className=\"glass-card rounded-3xl p-8 bg-white/[0.01]\">
650
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-cyan mb-6 flex items-center gap-2\">
651
  <span>💎</span> Technical Requirements
652
  </h4>
653
+ <ul className=\"space-y-4\">
654
  {activeAnalysis.key_requirements.map((req, i) => (
655
+ <li key={i} className=\"flex gap-4 text-sm text-slate-400 leading-relaxed\">
656
+ <span className=\"text-cyan font-bold\">▹</span> {req}
657
  </li>
658
  ))}
659
  </ul>
660
  </div>
661
  </div>
662
 
663
+ <div className=\"glass-card rounded-3xl p-10 bg-white/[0.01]\">
664
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-purple-400 mb-8 text-center\">Neural Risk Matrix</h4>
665
+ <div className=\"grid gap-6 md:grid-cols-2 mb-12\">
666
  {activeAnalysis.risks.map((risk, i) => (
667
+ <div key={i} className=\"group rounded-3xl bg-white/[0.02] p-6 border border-white/5 hover:border-purple-500/30 transition-all duration-300\">
668
+ <div className=\"flex items-center justify-between mb-4\">
669
+ <span className=\"font-bold text-white text-lg group-hover:text-purple-400 transition\">{risk.title}</span>
670
  <span className={`text-[9px] font-black px-3 py-1 rounded-full uppercase tracking-widest ${risk.severity === 'High' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-white/5 text-slate-500 border border-white/5'}`}>{risk.severity}</span>
671
  </div>
672
+ <p className=\"text-xs text-slate-500 leading-relaxed\">{risk.explanation}</p>
673
  </div>
674
  ))}
675
  </div>
676
 
677
  {activeAnalysis.strategic_roadmap && (
678
+ <div className=\"mt-8 pt-8 border-t border-white/5\">
679
+ <h4 className=\"text-[11px] font-bold uppercase tracking-widest text-cyan mb-6 text-center\">Winning Strategic Roadmap</h4>
680
+ <div className=\"p-8 rounded-3xl bg-cyan/5 border border-cyan/20 text-sm text-slate-300 leading-relaxed italic\">
681
+ <div className=\"prose prose-invert prose-sm max-w-none\">
682
  {activeAnalysis.strategic_roadmap.split('\n').map((line, i) => (
683
+ <p key={i} className=\"mb-2\">{line}</p>
684
  ))}
685
  </div>
686
  </div>
 
690
 
691
  </div>
692
 
693
+ <div className=\"lg:col-span-4\">
694
+ <div className=\"glass-card rounded-3xl p-8 bg-black/40 h-full sticky top-32\">
695
+ <div className=\"flex items-center gap-3 mb-8 border-b border-white/5 pb-6\">
696
+ <div className=\"h-2 w-2 rounded-full bg-purple-500 animate-pulse shadow-[0_0_12px_rgba(168,85,247,0.8)]\" />
697
+ <h4 className=\"text-[10px] font-bold uppercase tracking-widest text-slate-400\">Agent Intelligence Log</h4>
698
  </div>
699
+ <div className=\"space-y-8 overflow-y-auto max-h-[700px] pr-2 custom-scrollbar\">
700
  {activeAnalysis.audit_log?.map((log, i) => (
701
+ <div key={i} className=\"flex gap-5 group\">
702
+ <div className=\"flex flex-col items-center\">
703
+ <div className=\"h-8 w-8 rounded-xl bg-white/5 flex items-center justify-center text-sm border border-white/10 group-hover:border-purple-500/50 transition-all duration-300 shadow-lg\">🤖</div>
704
+ {i < (activeAnalysis.audit_log?.length ?? 0) - 1 && <div className=\"w-px flex-1 bg-gradient-to-b from-purple-500/40 to-transparent my-3\" />}
705
  </div>
706
+ <div className=\"pb-6\">
707
+ <p className=\"text-[13px] text-slate-400 leading-relaxed group-hover:text-white transition-colors duration-300\">{log}</p>
708
  </div>
709
  </div>
710
  ))}
 
713
 
714
  {/* Anexos Express Section */}
715
  {generatedAnnexes.length > 0 && (
716
+ <div id=\"annexes-section\" className=\"mt-8 glass-card rounded-3xl p-10 bg-purple-500/[0.03] border border-purple-500/20 animate-in fade-in slide-in-from-bottom-8 duration-700\">
717
+ <div className=\"flex items-center gap-4 mb-8\">
718
+ <div className=\"w-12 h-12 rounded-2xl bg-purple-500/20 flex items-center justify-center text-2xl shadow-lg shadow-purple-500/20\">📄</div>
719
  <div>
720
+ <h4 className=\"text-2xl font-black text-white tracking-tight\">Compliance: Anexos Express</h4>
721
+ <p className=\"text-slate-500 text-sm\">Official annexes pre-filled with company data and tender requirements.</p>
722
  </div>
723
  </div>
724
 
725
+ <div className=\"grid gap-6 md:grid-cols-1 lg:grid-cols-3\">
726
  {generatedAnnexes.map((annex, i) => (
727
+ <div key={i} className=\"group rounded-3xl bg-white/[0.02] border border-white/5 p-6 hover:border-purple-500/40 transition-all\">
728
+ <div className=\"text-[10px] font-bold uppercase text-purple-400 mb-3 tracking-widest\">Template Generated</div>
729
+ <h5 className=\"text-white font-bold mb-4 line-clamp-1\">{annex.name}</h5>
730
+ <div className=\"bg-black/40 rounded-xl p-4 text-[9px] font-mono text-slate-500 mb-4 h-32 overflow-hidden relative\">
731
+ <pre className=\"whitespace-pre-wrap\">{annex.content}</pre>
732
+ <div className=\"absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/60 to-transparent\" />
733
  </div>
734
  <button
735
  onClick={() => {
 
740
  a.download = `${annex.name.replace(/ /g, '_')}.md`;
741
  a.click();
742
  }}
743
+ className=\"w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-[10px] font-bold text-slate-400 hover:text-white hover:bg-white/10 transition uppercase tracking-widest\"
744
  >
745
  Download .md 📥
746
  </button>
 
755
 
756
  {/* Expert Consultation Chat */}
757
  {tender && (
758
+ <div className=\"mt-12 animate-in fade-in slide-in-from-bottom-8 duration-700\">
759
+ <div className=\"flex items-center gap-4 mb-6 px-2\">
760
+ <div className=\"w-10 h-10 rounded-2xl bg-purple-500/10 flex items-center justify-center text-xl shadow-lg shadow-purple-500/10\">💬</div>
761
  <div>
762
+ <h3 className=\"text-2xl font-black text-white tracking-tight\">Expert Agent Consultation</h3>
763
+ <p className=\"text-slate-500 text-sm\">Deep-dive into specific questions with our specialized AI agents.</p>
764
  </div>
765
  </div>
766
  <AgentChat tender={tender} companyProfile={companyProfile} />
frontend/components/TenderSearch.tsx CHANGED
@@ -207,7 +207,7 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
207
  <div className="glass-card rounded-[2.5rem] overflow-hidden border border-white/5 bg-slate-900/40 backdrop-blur-xl p-10 md:p-14 relative">
208
  <div className="absolute top-0 right-0 p-8">
209
  <a
210
- href={`https://www.mercadopublico.cl/fichaLicitacion.html?code=${tender.code}`}
211
  target="_blank"
212
  rel="noopener noreferrer"
213
  className="flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan/10 border border-cyan/30 text-cyan text-[10px] font-black uppercase tracking-widest hover:bg-cyan/20 transition-all"
 
207
  <div className="glass-card rounded-[2.5rem] overflow-hidden border border-white/5 bg-slate-900/40 backdrop-blur-xl p-10 md:p-14 relative">
208
  <div className="absolute top-0 right-0 p-8">
209
  <a
210
+ href={`https://www.mercadopublico.cl/Portal/BuscarLicitacion?Texto=${encodeURIComponent(tender.code)}`}
211
  target="_blank"
212
  rel="noopener noreferrer"
213
  className="flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan/10 border border-cyan/30 text-cyan text-[10px] font-black uppercase tracking-widest hover:bg-cyan/20 transition-all"
frontend/lib/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile, Tender, PurchaseOrder } from "./types";
2
 
3
  // Auto-detect API base URL based on environment
4
  export function getAPIBase(): string {
@@ -106,7 +106,8 @@ export async function analyzeTender(
106
  tender: Tender,
107
  companyProfile: CompanyProfile,
108
  documentText?: string,
109
- models?: Record<string, string>
 
110
  ): Promise<AnalysisResult> {
111
  const res = await fetch(`${API_BASE}/api/analyze`, {
112
  method: "POST",
@@ -115,7 +116,8 @@ export async function analyzeTender(
115
  tender,
116
  company_profile: companyProfile,
117
  document_text: documentText,
118
- models: models
 
119
  }),
120
  });
121
  if (!res.ok) {
@@ -199,3 +201,27 @@ export async function fetchPurchaseOrders(date?: string, status: string = "todos
199
  }
200
  return res.json();
201
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AnalysisHistoryItem, AnalysisResult, CompanyProfile, Tender, PurchaseOrder, TenderDetailInfo } from "./types";
2
 
3
  // Auto-detect API base URL based on environment
4
  export function getAPIBase(): string {
 
106
  tender: Tender,
107
  companyProfile: CompanyProfile,
108
  documentText?: string,
109
+ models?: Record<string, string>,
110
+ tenderDetails?: TenderDetailInfo | null
111
  ): Promise<AnalysisResult> {
112
  const res = await fetch(`${API_BASE}/api/analyze`, {
113
  method: "POST",
 
116
  tender,
117
  company_profile: companyProfile,
118
  document_text: documentText,
119
+ models: models,
120
+ tender_details: tenderDetails
121
  }),
122
  });
123
  if (!res.ok) {
 
201
  }
202
  return res.json();
203
  }
204
+
205
+ export async function fetchTenderDetails(code: string, qs?: string): Promise<TenderDetailInfo> {
206
+ const query = new URLSearchParams();
207
+ if (qs) query.append("qs", qs);
208
+
209
+ const res = await fetch(`${API_BASE}/api/tenders/${code}/detail-tabs?${query.toString()}`);
210
+ if (!res.ok) {
211
+ throw new Error("Error fetching tender details");
212
+ }
213
+ return res.json();
214
+ }
215
+
216
+ export async function extractTenderDetails(code: string, qs?: string): Promise<any> {
217
+ const query = new URLSearchParams();
218
+ if (qs) query.append("qs", qs);
219
+
220
+ const res = await fetch(`${API_BASE}/api/tenders/${code}/extract-details?${query.toString()}`, {
221
+ method: "POST"
222
+ });
223
+ if (!res.ok) {
224
+ throw new Error("Error extracting tender details");
225
+ }
226
+ return res.json();
227
+ }
frontend/lib/types.ts CHANGED
@@ -33,6 +33,8 @@ export type Tender = {
33
  attachments?: TenderAttachment[];
34
  evaluation_criteria?: { name?: string; weight?: string; description?: string }[];
35
  contract_duration?: string;
 
 
36
  };
37
 
38
  export type CompanyProfile = {
@@ -111,3 +113,23 @@ export type AnalysisHistoryItem = {
111
  analyzed_at: string;
112
  analysis: AnalysisResult;
113
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  attachments?: TenderAttachment[];
34
  evaluation_criteria?: { name?: string; weight?: string; description?: string }[];
35
  contract_duration?: string;
36
+ buyer_complaints?: number;
37
+ buyer_purchases?: number;
38
  };
39
 
40
  export type CompanyProfile = {
 
113
  analyzed_at: string;
114
  analysis: AnalysisResult;
115
  };
116
+
117
+ export type TenderDetailTab = {
118
+ name: string;
119
+ found: boolean;
120
+ };
121
+
122
+ export type TenderDetailInfo = {
123
+ tender_code: string;
124
+ url: string;
125
+ tabs: Record<string, TenderDetailTab>;
126
+ attachments: TenderAttachment[];
127
+ metadata: {
128
+ has_administrative_docs?: boolean;
129
+ has_technical_docs?: boolean;
130
+ has_economic_docs?: boolean;
131
+ question_count?: number;
132
+ has_adjudication?: boolean;
133
+ };
134
+ error?: string;
135
+ };