Álvaro Valenzuela Valdes commited on
Commit
0d71eae
·
1 Parent(s): 8a93890

feat: enhance AI agents with detailed Mercado Publico data and update frontend UI

Browse files
backend/api_sample_detail.json ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Cantidad": 1,
3
+ "FechaCreacion": "2026-05-06T02:03:14.3264192Z",
4
+ "Version": "v1",
5
+ "Listado": [
6
+ {
7
+ "CodigoExterno": "1000-6-LE26",
8
+ "Nombre": "Sum. material p\u00e9treo Comunas Cabrero y Yumbel.",
9
+ "CodigoEstado": 5,
10
+ "Descripcion": "Para obras de recebo de carpetas granulares del programa de trabajo o para emergencias de caminos de las provincias de Arauco, Biob\u00edo y Concepci\u00f3n.",
11
+ "FechaCierre": null,
12
+ "Estado": "Publicada",
13
+ "Comprador": {
14
+ "CodigoOrganismo": "7248",
15
+ "NombreOrganismo": "MINISTERIO DE OBRAS PUBLICAS DIREC CION GRAL DE OO PP DCYF",
16
+ "RutUnidad": "61.202.000-0",
17
+ "CodigoUnidad": "1996",
18
+ "NombreUnidad": "Direcci\u00f3n de Vialidad - VIII Regi\u00f3n - Provincia Bio Bio",
19
+ "DireccionUnidad": "Avda. Ricardo Vicu\u00f1a N\u00ba 243, Los Angeles",
20
+ "ComunaUnidad": "Los Angeles",
21
+ "RegionUnidad": "Regi\u00f3n del Biob\u00edo ",
22
+ "RutUsuario": "",
23
+ "CodigoUsuario": "1406365",
24
+ "NombreUsuario": "Ghislaine Bruning Cereceda",
25
+ "CargoUsuario": "Analista de Conservaci\u00f3n"
26
+ },
27
+ "DiasCierreLicitacion": "3",
28
+ "Informada": 0,
29
+ "CodigoTipo": 1,
30
+ "Tipo": "LE",
31
+ "TipoConvocatoria": "1",
32
+ "Moneda": "CLP",
33
+ "Etapas": 1,
34
+ "EstadoEtapas": "0",
35
+ "TomaRazon": "0",
36
+ "EstadoPublicidadOfertas": 1,
37
+ "JustificacionPublicidad": "",
38
+ "Contrato": "2",
39
+ "Obras": "0",
40
+ "CantidadReclamos": 459,
41
+ "Fechas": {
42
+ "FechaCreacion": "2026-04-24T11:02:24.817",
43
+ "FechaCierre": "2026-05-08T15:10:00",
44
+ "FechaInicio": "2026-04-28T15:20:00",
45
+ "FechaFinal": "2026-05-05T18:00:00",
46
+ "FechaPubRespuestas": "2026-05-06T18:00:00",
47
+ "FechaActoAperturaTecnica": "2026-05-08T15:10:00",
48
+ "FechaActoAperturaEconomica": "2026-05-08T15:10:00",
49
+ "FechaPublicacion": "2026-04-28T14:16:06.67",
50
+ "FechaAdjudicacion": "2026-05-25T18:00:00",
51
+ "FechaEstimadaAdjudicacion": "2026-05-25T18:00:00",
52
+ "FechaSoporteFisico": null,
53
+ "FechaTiempoEvaluacion": null,
54
+ "FechaEstimadaFirma": null,
55
+ "FechasUsuario": null,
56
+ "FechaVisitaTerreno": null,
57
+ "FechaEntregaAntecedentes": null
58
+ },
59
+ "UnidadTiempoEvaluacion": 1,
60
+ "DireccionVisita": "",
61
+ "DireccionEntrega": "",
62
+ "Estimacion": 2,
63
+ "FuenteFinanciamiento": "Fondo sectorial",
64
+ "VisibilidadMonto": 0,
65
+ "MontoEstimado": null,
66
+ "Tiempo": "30",
67
+ "UnidadTiempo": "1",
68
+ "Modalidad": 1,
69
+ "TipoPago": "1",
70
+ "NombreResponsablePago": "MOP, Direcci\u00f3n de Vialidad",
71
+ "EmailResponsablePago": "",
72
+ "NombreResponsableContrato": "Se dar\u00e1 a conocer en la resoluci\u00f3n de adjudicaci\u00f3n",
73
+ "EmailResponsableContrato": "",
74
+ "FonoResponsableContrato": "",
75
+ "ProhibicionContratacion": "Por resguardo del inter\u00e9s fiscal.",
76
+ "SubContratacion": "0",
77
+ "UnidadTiempoDuracionContrato": 2,
78
+ "TiempoDuracionContrato": "30",
79
+ "TipoDuracionContrato": " ",
80
+ "JustificacionMontoEstimado": "",
81
+ "ObservacionContract": null,
82
+ "ExtensionPlazo": 0,
83
+ "EsBaseTipo": 0,
84
+ "UnidadTiempoContratoLicitacion": "2",
85
+ "ValorTiempoRenovacion": "0",
86
+ "PeriodoTiempoRenovacion": " ",
87
+ "EsRenovable": 0,
88
+ "CodigoBIP": null,
89
+ "Adjudicacion": null,
90
+ "Items": {
91
+ "Cantidad": 1,
92
+ "Listado": [
93
+ {
94
+ "Correlativo": 1,
95
+ "CodigoProducto": 11111611,
96
+ "CodigoCategoria": "11111600",
97
+ "Categoria": "Productos derivados de minerales, plantas y animales / Tierra y piedra / Piedras",
98
+ "NombreProducto": "Grava",
99
+ "Descripcion": "SUMINISTRO DE MATERIAL P\u00c9TREO PARA CAMINOS PERTENECIENTES A LAS COMUNAS DE YUMBEL Y CABRERO, PROVINCIA DE BIOB\u00cdO, REGI\u00d3N DEL BIOB\u00cdO.",
100
+ "UnidadMedida": "Metro C\u00fabico",
101
+ "Cantidad": 2000.0,
102
+ "Adjudicacion": null
103
+ }
104
+ ]
105
+ }
106
+ }
107
+ ]
108
+ }
backend/app/models/tender.py CHANGED
@@ -9,11 +9,16 @@ class TenderModel(Base):
9
  name = Column(String(255), index=True)
10
  buyer = Column(String(255), index=True)
11
  status = Column(String(100))
 
 
 
12
  closing_date = Column(DateTime, nullable=True)
 
13
  description = Column(Text)
14
  estimated_amount = Column(Float, nullable=True)
15
  source = Column(String(50), default="Mercado Publico")
16
  region = Column(String(100), nullable=True)
 
17
  sector = Column(String(100), nullable=True)
18
 
19
  # Storage for nested structures as JSON for simplicity in this hackathon
 
9
  name = Column(String(255), index=True)
10
  buyer = Column(String(255), index=True)
11
  status = Column(String(100))
12
+ status_code = Column(String(10), nullable=True)
13
+ type = Column(String(20), nullable=True)
14
+ currency = Column(String(10), nullable=True)
15
  closing_date = Column(DateTime, nullable=True)
16
+ publication_date = Column(DateTime, nullable=True)
17
  description = Column(Text)
18
  estimated_amount = Column(Float, nullable=True)
19
  source = Column(String(50), default="Mercado Publico")
20
  region = Column(String(100), nullable=True)
21
+ buyer_region = Column(String(100), nullable=True)
22
  sector = Column(String(100), nullable=True)
23
 
24
  # Storage for nested structures as JSON for simplicity in this hackathon
backend/app/schemas/tender.py CHANGED
@@ -3,7 +3,11 @@ from typing import List, Optional, Union
3
  from datetime import datetime
4
 
5
  class TenderItem(BaseModel):
 
 
 
6
  name: str
 
7
  quantity: float
8
  unit: str
9
 
@@ -16,13 +20,19 @@ class Tender(BaseModel):
16
 
17
  code: str
18
  name: str
 
19
  buyer: str
 
20
  status: str
 
 
 
21
  closing_date: Union[str, datetime, None] = None
22
- description: str
23
  estimated_amount: Optional[float] = None
24
- source: str
25
  region: Optional[str] = None
26
  sector: Optional[str] = None
27
  items: List[TenderItem] = []
28
  attachments: List[TenderAttachment] = []
 
 
3
  from datetime import datetime
4
 
5
  class TenderItem(BaseModel):
6
+ correlative: Optional[int] = None
7
+ product_code: Optional[str] = None
8
+ category: Optional[str] = None
9
  name: str
10
+ description: Optional[str] = None
11
  quantity: float
12
  unit: str
13
 
 
20
 
21
  code: str
22
  name: str
23
+ description: str
24
  buyer: str
25
+ buyer_region: Optional[str] = None
26
  status: str
27
+ status_code: Optional[int] = None
28
+ type: Optional[str] = None # L1, LE, LP, etc.
29
+ currency: Optional[str] = None # CLP, USD, etc.
30
  closing_date: Union[str, datetime, None] = None
31
+ publication_date: Union[str, datetime, None] = None
32
  estimated_amount: Optional[float] = None
33
+ source: str = "Mercado Público"
34
  region: Optional[str] = None
35
  sector: Optional[str] = None
36
  items: List[TenderItem] = []
37
  attachments: List[TenderAttachment] = []
38
+ raw_data: Optional[dict] = None # Store the full response if needed
backend/app/services/agents.py CHANGED
@@ -9,23 +9,27 @@ from app.services.report import generate_markdown_report
9
 
10
  async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
11
  prompt = (
12
- f"AGENT ROLE: Legal & Compliance Expert\n"
13
  f"GOAL: Analyze administrative bases and compliance risks.\n"
14
- f"TENDER: {tender.name} - {tender.description}\n"
 
 
15
  f"COMPANY: {company.name} (Docs: {', '.join(company.documents_available)})\n"
16
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
17
- f"TASK: Identify 3 legal gaps and 2 critical deadlines. Be concise and professional."
18
  )
19
  return await asyncio.to_thread(call_gemini, prompt)
20
 
21
  async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
 
22
  prompt = (
23
  f"AGENT ROLE: Technical Architect\n"
24
  f"GOAL: Evaluate technical feasibility and product-market fit.\n"
25
  f"TENDER: {tender.name} - {tender.description}\n"
 
26
  f"COMPANY: {company.industry} - {company.experience}\n"
27
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
28
- f"TASK: Identify 3 technical challenges and how our stack fits. Be concise."
29
  )
30
  return await asyncio.to_thread(call_gemini, prompt)
31
 
@@ -33,9 +37,11 @@ async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_
33
  prompt = (
34
  f"AGENT ROLE: Risk & Strategy Specialist\n"
35
  f"GOAL: Calculate ROI, competitive risks, and overall strategy.\n"
36
- f"TENDER: {tender.name} - {tender.estimated_amount} CLP\n"
 
 
37
  f"COMPANY: {company.name}\n"
38
- f"TASK: Identify 3 strategic risks and 1 'Win Strategy'. Be concise."
39
  )
40
  return await asyncio.to_thread(call_gemini, prompt)
41
 
 
9
 
10
  async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
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"STATUS: {tender.status} (Code: {tender.status_code})\n"
16
+ f"DATES: Published: {tender.publication_date}, Closing: {tender.closing_date}\n"
17
  f"COMPANY: {company.name} (Docs: {', '.join(company.documents_available)})\n"
18
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
19
+ f"TASK: Identify 3 legal gaps. Analyze if the timeline is 'Express' (suspiciously short) for the tender type {tender.type}. Verify if company documents meet common requirements for this tender level."
20
  )
21
  return await asyncio.to_thread(call_gemini, prompt)
22
 
23
  async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "") -> str:
24
+ items_summary = ", ".join([f"{i.name} ({i.quantity} {i.unit})" for i in (tender.items or [])[:10]])
25
  prompt = (
26
  f"AGENT ROLE: Technical Architect\n"
27
  f"GOAL: Evaluate technical feasibility and product-market fit.\n"
28
  f"TENDER: {tender.name} - {tender.description}\n"
29
+ f"LINE ITEMS: {items_summary}\n"
30
  f"COMPANY: {company.industry} - {company.experience}\n"
31
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
32
+ f"TASK: Analyze specific LINE ITEMS against company capabilities. Identify 3 technical challenges. Is this a generic 'buy' or a complex project?"
33
  )
34
  return await asyncio.to_thread(call_gemini, prompt)
35
 
 
37
  prompt = (
38
  f"AGENT ROLE: Risk & Strategy Specialist\n"
39
  f"GOAL: Calculate ROI, competitive risks, and overall strategy.\n"
40
+ f"TENDER: {tender.name}\n"
41
+ f"AMOUNT: {tender.estimated_amount} {tender.currency}\n"
42
+ f"DATES: Closing on {tender.closing_date}\n"
43
  f"COMPANY: {company.name}\n"
44
+ f"TASK: Identify 3 strategic risks. Pay special attention to CURRENCY RISK if not CLP ({tender.currency}). Suggest a 'Win Strategy' based on the tender type {tender.type}."
45
  )
46
  return await asyncio.to_thread(call_gemini, prompt)
47
 
backend/app/services/mercado_publico.py CHANGED
@@ -1,96 +1,187 @@
1
  import httpx
2
- from typing import List, Optional
3
  from app.config import settings
4
- from app.schemas.tender import Tender
5
  from datetime import datetime
6
 
7
  API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
8
 
9
- async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
10
- """
11
- Fetches tenders from Mercado Público API and maps them to our Tender schema.
12
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  if not settings.mercado_publico_ticket:
14
  print("⚠️ No Mercado Público Ticket configured.")
15
  return []
16
 
17
- # Format date as ddmmaaaa if provided, otherwise use today
18
- search_date = date if date else datetime.now().strftime("%d%m%Y")
19
-
20
- params = {
21
- "ticket": settings.mercado_publico_ticket,
22
- "fecha": search_date,
23
- "estado": "activas"
24
- }
25
 
26
  try:
27
  async with httpx.AsyncClient(timeout=30.0) as client:
28
- response = client.get(API_BASE, params=params)
29
  response.raise_for_status()
30
  data = response.json()
31
 
32
  raw_list = data.get("Listado", [])
33
- results = []
34
-
35
- for item in raw_list:
36
- # Basic filter by keyword if provided (the MP API doesn't support keyword search directly in this endpoint)
37
- if keyword and keyword.lower() not in item.get("Nombre", "").lower():
38
- continue
39
-
40
- results.append(Tender(
41
- code=item.get("CodigoExterno", ""),
42
- name=item.get("Nombre", ""),
43
- description=item.get("Descripcion", item.get("Nombre", "")),
44
- buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
45
- status=item.get("Estado", "Publicada"),
46
- closing_date=item.get("FechaCierre", ""),
47
- estimated_amount=float(item.get("MontoEstimado", 0)) if item.get("MontoEstimado") else None,
48
- source="Mercado Público",
49
- region="Nacional", # MP API provides this in detail view usually
50
- sector="General",
51
- items=[],
52
- attachments=[]
53
- ))
54
- return results
55
  except Exception as e:
56
- print(f"❌ API Error in fetch_tenders: {e}")
57
  return []
58
 
59
- async def get_tender_by_code(code: str) -> Optional[Tender]:
 
 
 
 
 
 
 
 
60
  """
61
- Fetches a specific tender by its code.
 
62
  """
63
- if not settings.mercado_publico_ticket:
64
- return None
 
 
65
 
66
- params = {
67
- "ticket": settings.mercado_publico_ticket,
68
- "codigo": code
69
- }
70
 
71
- try:
72
- async with httpx.AsyncClient(timeout=30.0) as client:
73
- response = client.get(API_BASE, params=params)
74
- response.raise_for_status()
75
- data = response.json()
76
-
77
- if "Listado" in data and len(data["Listado"]) > 0:
78
- item = data["Listado"][0]
79
- return Tender(
80
- code=item.get("CodigoExterno", ""),
81
- name=item.get("Nombre", ""),
82
- description=item.get("Descripcion", item.get("Nombre", "")),
83
- buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
84
- status=item.get("Estado", "Publicada"),
85
- closing_date=item.get("FechaCierre", ""),
86
- estimated_amount=float(item.get("MontoEstimado", 0)) if item.get("MontoEstimado") else None,
87
- source="Mercado Público",
88
- region="Nacional",
89
- sector="General",
90
- items=[],
91
- attachments=[]
92
- )
93
- return None
94
- except Exception as e:
95
- print(f"❌ API Error in get_tender_by_code: {e}")
96
- return None
 
1
  import httpx
2
+ from typing import List, Optional, Dict, Any
3
  from app.config import settings
4
+ from app.schemas.tender import Tender, TenderItem
5
  from datetime import datetime
6
 
7
  API_BASE = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
8
 
9
+ # Constants from documentation
10
+ STATUS_CODES = {
11
+ "5": "Publicada",
12
+ "6": "Cerrada",
13
+ "7": "Desierta",
14
+ "8": "Adjudicada",
15
+ "18": "Revocada",
16
+ "19": "Suspendida"
17
+ }
18
+
19
+ TENDER_TYPES = {
20
+ "L1": "Licitación Pública Menor a 100 UTM",
21
+ "LE": "Licitación Pública Entre 100 y 1000 UTM",
22
+ "LP": "Licitación Pública Mayor 1000 UTM",
23
+ "LS": "Licitación Pública Servicios personales especializados",
24
+ "A1": "Licitación Privada por Licitación Pública anterior sin oferentes",
25
+ "B1": "Licitación Privada por otras causales, excluidas de la ley de Compras",
26
+ "J1": "Licitación Privada por Servicios de Naturaleza Confidencial",
27
+ "F1": "Licitación Privada por Convenios con Personas Jurídicas Extranjeras",
28
+ "E1": "Licitación Privada por Remanente de Contrato anterior",
29
+ "CO": "Licitación Privada entre 100 y 1000 UTM",
30
+ "B2": "Licitación Privada Mayor a 1000 UTM",
31
+ "A2": "Trato Directo por Producto de Licitación Privada anterior sin oferentes o desierta",
32
+ "D1": "Trato Directo por Proveedor Único",
33
+ "E2": "Licitación Privada Menor a 100 UTM",
34
+ "C2": "Trato Directo (Cotización)",
35
+ "C1": "Compra Directa (Orden de compra)",
36
+ "F2": "Trato Directo (Cotización)",
37
+ "F3": "Compra Directa (Orden de compra)",
38
+ "G2": "Directo (Cotización)",
39
+ "G1": "Compra Directa (Orden de compra)",
40
+ "R1": "Orden de Compra menor a 3 UTM",
41
+ "CA": "Orden de Compra sin Resolución",
42
+ "SE": "Orden de Compra proveniente de adquisición sin emisión automática de OC"
43
+ }
44
+
45
+ CURRENCIES = {
46
+ "CLP": "Peso Chileno",
47
+ "CLF": "Unidad de Fomento",
48
+ "USD": "Dólar Americano",
49
+ "UTM": "Unidad Tributaria Mensual",
50
+ "EUR": "Euro"
51
+ }
52
+
53
+ PAYMENT_MODALITIES = {
54
+ "1": "Pago a 30 días",
55
+ "2": "Pago a 30, 60 y 90 días",
56
+ "3": "Pago al día",
57
+ "4": "Pago Anual",
58
+ "5": "Pago a 60 días",
59
+ "6": "Pagos Mensuales",
60
+ "7": "Pago Contra Entrega Conforme",
61
+ "8": "Pago Bimensual",
62
+ "9": "Pago Por Estado de Avance",
63
+ "10": "Pago Trimestral"
64
+ }
65
+
66
+ TIME_UNITS = {
67
+ "1": "Horas",
68
+ "2": "Días",
69
+ "3": "Semanas",
70
+ "4": "Meses",
71
+ "5": "Años"
72
+ }
73
+
74
+ def map_raw_to_tender(item: Dict[str, Any]) -> Tender:
75
+ """Maps raw API item to Tender schema."""
76
+ # Handle Items
77
+ items_list = []
78
+ raw_items = item.get("Items", {})
79
+ if isinstance(raw_items, dict) and "Listado" in raw_items:
80
+ for i in raw_items["Listado"]:
81
+ items_list.append(TenderItem(
82
+ correlative=i.get("Correlativo"),
83
+ product_code=str(i.get("CodigoProducto", "")),
84
+ category=i.get("Categoria"),
85
+ name=i.get("NombreProducto", ""),
86
+ description=i.get("Descripcion"),
87
+ quantity=float(i.get("Cantidad", 0)),
88
+ unit=i.get("UnidadMedida", "")
89
+ ))
90
+
91
+ # Handle dates
92
+ fechas = item.get("Fechas", {})
93
+ closing_date = fechas.get("FechaCierre") or item.get("FechaCierre")
94
+ pub_date = fechas.get("FechaPublicacion")
95
+
96
+ return Tender(
97
+ code=item.get("CodigoExterno", ""),
98
+ name=item.get("Nombre", ""),
99
+ description=item.get("Descripcion", item.get("Nombre", "")),
100
+ buyer=item.get("Comprador", {}).get("NombreOrganismo", "Unknown"),
101
+ buyer_region=item.get("Comprador", {}).get("RegionUnidad"),
102
+ status=item.get("Estado", "Publicada"),
103
+ status_code=item.get("CodigoEstado"),
104
+ type=item.get("Tipo"),
105
+ currency=item.get("Moneda"),
106
+ closing_date=closing_date,
107
+ publication_date=pub_date,
108
+ estimated_amount=float(item.get("MontoEstimado", 0)) if item.get("MontoEstimado") else None,
109
+ source="Mercado Público",
110
+ region=item.get("Comprador", {}).get("RegionUnidad", "Nacional"),
111
+ sector="Public",
112
+ items=items_list,
113
+ attachments=[], # API v1 doesn't seem to provide attachments directly in this list
114
+ raw_data=item
115
+ )
116
+
117
+ async def _fetch(params: Dict[str, str]) -> List[Tender]:
118
+ """Helper to perform the actual API request."""
119
  if not settings.mercado_publico_ticket:
120
  print("⚠️ No Mercado Público Ticket configured.")
121
  return []
122
 
123
+ params["ticket"] = settings.mercado_publico_ticket
 
 
 
 
 
 
 
124
 
125
  try:
126
  async with httpx.AsyncClient(timeout=30.0) as client:
127
+ response = await client.get(API_BASE, params=params)
128
  response.raise_for_status()
129
  data = response.json()
130
 
131
  raw_list = data.get("Listado", [])
132
+ if raw_list is None: # Sometimes API returns null for Listado
133
+ return []
134
+
135
+ return [map_raw_to_tender(item) for item in raw_list]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  except Exception as e:
137
+ print(f"❌ API Error fetching with params {params}: {e}")
138
  return []
139
 
140
+ async def get_active_tenders() -> List[Tender]:
141
+ """Shows all tenders published on the day of the query."""
142
+ return await _fetch({"estado": "activas"})
143
+
144
+ async def get_tenders_by_date(date: str) -> List[Tender]:
145
+ """Fetches all tenders for a specific date (format ddmmaaaa)."""
146
+ return await _fetch({"fecha": date})
147
+
148
+ async def get_tenders_by_status_and_date(status: str, date: Optional[str] = None) -> List[Tender]:
149
  """
150
+ Fetches tenders by status and date.
151
+ Status can be: publicada, cerrada, desierta, adjudicada, revocada, suspendida, todos.
152
  """
153
+ params = {"estado": status}
154
+ if date:
155
+ params["fecha"] = date
156
+ return await _fetch(params)
157
 
158
+ async def get_tender_by_code(code: str) -> Optional[Tender]:
159
+ """Fetches detailed information for a specific tender code."""
160
+ results = await _fetch({"codigo": code})
161
+ return results[0] if results else None
162
 
163
+ async def get_tenders_by_provider(provider_code: str, date: str) -> List[Tender]:
164
+ """Fetches tenders for a specific provider on a specific date."""
165
+ return await _fetch({"CodigoProveedor": provider_code, "fecha": date})
166
+
167
+ async def get_tenders_by_org(org_code: str, date: str) -> List[Tender]:
168
+ """Fetches tenders for a specific public organization on a specific date."""
169
+ return await _fetch({"CodigoOrganismo": org_code, "fecha": date})
170
+
171
+ async def fetch_tenders(keyword: Optional[str] = None, date: Optional[str] = None) -> List[Tender]:
172
+ """
173
+ Legacy/Wrapper function for general search.
174
+ """
175
+ search_date = date if date else datetime.now().strftime("%d%m%Y")
176
+
177
+ # We use active tenders as default if no date is provided
178
+ if not date:
179
+ tenders = await get_active_tenders()
180
+ else:
181
+ tenders = await get_tenders_by_date(search_date)
182
+
183
+ if keyword:
184
+ keyword = keyword.lower()
185
+ tenders = [t for t in tenders if keyword in t.name.lower() or keyword in t.description.lower()]
186
+
187
+ return tenders
 
backend/app/services/sync.py CHANGED
@@ -28,20 +28,35 @@ async def sync_tenders_to_db(db: Session, keyword: str = None):
28
  # Check if exists
29
  db_tender = db.query(TenderModel).filter(TenderModel.code == api_t.code).first()
30
 
 
 
 
 
 
 
 
 
 
 
31
  # Convert Pydantic model to dict for DB
32
  tender_data = {
33
  "code": api_t.code,
34
  "name": api_t.name,
35
  "buyer": api_t.buyer,
 
36
  "status": api_t.status,
37
- "closing_date": datetime.fromisoformat(api_t.closing_date.replace("Z", "")) if api_t.closing_date else None,
 
 
 
 
38
  "description": api_t.description,
39
  "estimated_amount": api_t.estimated_amount,
40
  "source": api_t.source,
41
  "region": api_t.region,
42
  "sector": api_t.sector,
43
- "items": [item.dict() for item in api_t.items] if api_t.items else [],
44
- "attachments": [att.dict() for att in api_t.attachments] if api_t.attachments else []
45
  }
46
 
47
  if db_tender:
 
28
  # Check if exists
29
  db_tender = db.query(TenderModel).filter(TenderModel.code == api_t.code).first()
30
 
31
+ # Helper to parse dates
32
+ def parse_dt(dt_str):
33
+ if not dt_str: return None
34
+ try:
35
+ # Handle Z and other common formats
36
+ clean_str = dt_str.replace("Z", "").split(".")[0]
37
+ return datetime.fromisoformat(clean_str)
38
+ except:
39
+ return None
40
+
41
  # Convert Pydantic model to dict for DB
42
  tender_data = {
43
  "code": api_t.code,
44
  "name": api_t.name,
45
  "buyer": api_t.buyer,
46
+ "buyer_region": api_t.buyer_region,
47
  "status": api_t.status,
48
+ "status_code": str(api_t.status_code) if api_t.status_code else None,
49
+ "type": api_t.type,
50
+ "currency": api_t.currency,
51
+ "closing_date": parse_dt(api_t.closing_date) if isinstance(api_t.closing_date, str) else api_t.closing_date,
52
+ "publication_date": parse_dt(api_t.publication_date) if isinstance(api_t.publication_date, str) else api_t.publication_date,
53
  "description": api_t.description,
54
  "estimated_amount": api_t.estimated_amount,
55
  "source": api_t.source,
56
  "region": api_t.region,
57
  "sector": api_t.sector,
58
+ "items": [item.model_dump() for item in api_t.items] if api_t.items else [],
59
+ "attachments": [att.model_dump() for att in api_t.attachments] if api_t.attachments else []
60
  }
61
 
62
  if db_tender:
backend/migrate_db.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+
4
+ db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "andesops.db")
5
+
6
+ def migrate():
7
+ if not os.path.exists(db_path):
8
+ print(f"Database not found at {db_path}")
9
+ return
10
+
11
+ conn = sqlite3.connect(db_path)
12
+ cursor = conn.cursor()
13
+
14
+ columns_to_add = [
15
+ ("status_code", "VARCHAR(10)"),
16
+ ("type", "VARCHAR(20)"),
17
+ ("currency", "VARCHAR(10)"),
18
+ ("publication_date", "DATETIME"),
19
+ ("buyer_region", "VARCHAR(100)")
20
+ ]
21
+
22
+ for col_name, col_type in columns_to_add:
23
+ try:
24
+ cursor.execute(f"ALTER TABLE tenders ADD COLUMN {col_name} {col_type}")
25
+ print(f"Added column {col_name}")
26
+ except sqlite3.OperationalError as e:
27
+ if "duplicate column name" in str(e).lower():
28
+ print(f"Column {col_name} already exists.")
29
+ else:
30
+ print(f"Error adding {col_name}: {e}")
31
+
32
+ conn.commit()
33
+ conn.close()
34
+ print("Migration finished.")
35
+
36
+ if __name__ == "__main__":
37
+ migrate()
backend/scratch_test_api.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import asyncio
3
+ import json
4
+
5
+ async def test_full_api():
6
+ ticket = "99B4CA8C-C1DF-4E3F-B5CF-C1672D432A91"
7
+
8
+ # 1. Fetch active tenders
9
+ url_active = f"https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json?estado=activas&ticket={ticket}"
10
+ print(f"Fetching active tenders: {url_active}")
11
+
12
+ async with httpx.AsyncClient(timeout=30) as client:
13
+ try:
14
+ resp = await client.get(url_active)
15
+ data = resp.json()
16
+ items = data.get("Listado", [])
17
+ print(f"Found {len(items)} active items.")
18
+
19
+ if items:
20
+ code = items[0].get("CodigoExterno")
21
+ print(f"Fetching details for code: {code}")
22
+
23
+ url_detail = f"https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json?codigo={code}&ticket={ticket}"
24
+ resp_detail = await client.get(url_detail)
25
+ detail_data = resp_detail.json()
26
+
27
+ print("Detail sample:")
28
+ print(json.dumps(detail_data, indent=2))
29
+
30
+ # Save to file for reference
31
+ with open("api_sample_detail.json", "w") as f:
32
+ json.dump(detail_data, f, indent=2)
33
+
34
+ except Exception as e:
35
+ print(f"Error: {e}")
36
+
37
+ if __name__ == "__main__":
38
+ asyncio.run(test_full_api())
frontend/components/TenderSearch.tsx CHANGED
@@ -313,11 +313,18 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
313
  </td>
314
  <td className="px-4 py-5 text-slate-400 text-[11px] truncate">{tender.buyer}</td>
315
  <td className="px-4 py-5 text-center">
316
- <span className={`inline-block rounded-full px-2 py-0.5 text-[9px] font-bold ${
317
- tender.status.toLowerCase().includes('publicada') ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-slate-800/50 text-slate-500'
318
- }`}>
319
- {tender.status}
320
- </span>
 
 
 
 
 
 
 
321
  </td>
322
  </tr>
323
  ))}
@@ -377,7 +384,11 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
377
  </div>
378
  <div className="flex items-center gap-3">
379
  <div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
380
- <span className="text-slate-400 font-medium">{selectedTenderForModal.region || "Nacional"}</span>
 
 
 
 
381
  </div>
382
  </div>
383
  </div>
@@ -393,17 +404,57 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
393
  <span className="w-8 h-[1px] bg-slate-700" />
394
  Project Scope & Description
395
  </h4>
396
- <div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap font-light">
397
  {selectedTenderForModal.description || "No detailed description provided."}
398
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  </section>
400
 
401
  <div className="grid grid-cols-2 gap-6">
402
  <div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
403
  <div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Estimated Investment</div>
404
  <div className="text-xl text-white font-bold tracking-tight">
405
- {selectedTenderForModal.estimated_amount ? new Intl.NumberFormat("es-CL", { style: "currency", currency: "CLP" }).format(selectedTenderForModal.estimated_amount) : "Not Disclosed"}
 
 
406
  </div>
 
 
 
407
  </div>
408
  <div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
409
  <div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
@@ -416,11 +467,27 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
416
  <div className="space-y-12">
417
  <div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20 shadow-2xl shadow-purple-500/5 relative overflow-hidden group">
418
  <div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/20 blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity" />
419
- <h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-purple-400 mb-6">Submission Deadline</h4>
420
- <div className="text-2xl font-black text-white mb-2 font-mono">
421
- {selectedTenderForModal.closing_date ? new Date(selectedTenderForModal.closing_date).toLocaleDateString() : "---"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  </div>
423
- <p className="text-[10px] text-purple-400/60 font-bold uppercase tracking-tighter">Final Window for Submission</p>
 
424
  </div>
425
 
426
  <div>
 
313
  </td>
314
  <td className="px-4 py-5 text-slate-400 text-[11px] truncate">{tender.buyer}</td>
315
  <td className="px-4 py-5 text-center">
316
+ <div className="flex flex-col items-center gap-1">
317
+ <span className={`inline-block rounded-full px-2 py-0.5 text-[9px] font-bold ${
318
+ tender.status.toLowerCase().includes('publicada') ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-slate-800/50 text-slate-500'
319
+ }`}>
320
+ {tender.status}
321
+ </span>
322
+ {tender.type && (
323
+ <span className="text-[8px] font-mono text-slate-600 bg-white/5 px-1 rounded">
324
+ {tender.type}
325
+ </span>
326
+ )}
327
+ </div>
328
  </td>
329
  </tr>
330
  ))}
 
384
  </div>
385
  <div className="flex items-center gap-3">
386
  <div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
387
+ <span className="text-slate-400 font-medium">{selectedTenderForModal.buyer_region || selectedTenderForModal.region || "Nacional"}</span>
388
+ </div>
389
+ <div className="flex items-center gap-3">
390
+ <div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">🏷️</div>
391
+ <span className="text-slate-400 font-medium">Type: {selectedTenderForModal.type || "N/A"}</span>
392
  </div>
393
  </div>
394
  </div>
 
404
  <span className="w-8 h-[1px] bg-slate-700" />
405
  Project Scope & Description
406
  </h4>
407
+ <div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap font-light mb-12">
408
  {selectedTenderForModal.description || "No detailed description provided."}
409
  </div>
410
+
411
+ {selectedTenderForModal.items && selectedTenderForModal.items.length > 0 && (
412
+ <section>
413
+ <h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-500 mb-6 flex items-center gap-3">
414
+ <span className="w-8 h-[1px] bg-slate-700" />
415
+ Line Items & Requirements
416
+ </h4>
417
+ <div className="overflow-hidden rounded-3xl border border-white/5 bg-white/[0.01]">
418
+ <table className="w-full text-left text-xs">
419
+ <thead className="bg-white/5 text-slate-500 uppercase font-black tracking-tighter">
420
+ <tr>
421
+ <th className="px-6 py-4">Item / Product</th>
422
+ <th className="px-6 py-4">Category</th>
423
+ <th className="px-6 py-4 text-right">Quantity</th>
424
+ </tr>
425
+ </thead>
426
+ <tbody className="divide-y divide-white/5">
427
+ {selectedTenderForModal.items.map((item, idx) => (
428
+ <tr key={idx} className="hover:bg-white/[0.02]">
429
+ <td className="px-6 py-4">
430
+ <div className="text-slate-200 font-bold">{item.name}</div>
431
+ <div className="text-[10px] text-slate-500 mt-1">{item.description}</div>
432
+ </td>
433
+ <td className="px-6 py-4 text-slate-400">{item.category || "N/A"}</td>
434
+ <td className="px-6 py-4 text-right">
435
+ <span className="text-cyan font-mono font-bold">{item.quantity}</span>
436
+ <span className="ml-1 text-slate-500 uppercase text-[10px]">{item.unit}</span>
437
+ </td>
438
+ </tr>
439
+ ))}
440
+ </tbody>
441
+ </table>
442
+ </div>
443
+ </section>
444
+ )}
445
  </section>
446
 
447
  <div className="grid grid-cols-2 gap-6">
448
  <div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
449
  <div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Estimated Investment</div>
450
  <div className="text-xl text-white font-bold tracking-tight">
451
+ {selectedTenderForModal.estimated_amount
452
+ ? new Intl.NumberFormat("es-CL", { style: "currency", currency: selectedTenderForModal.currency || "CLP" }).format(selectedTenderForModal.estimated_amount)
453
+ : "Not Disclosed"}
454
  </div>
455
+ {selectedTenderForModal.currency && selectedTenderForModal.currency !== 'CLP' && (
456
+ <div className="text-[10px] text-cyan mt-1 font-bold">Currency: {selectedTenderForModal.currency}</div>
457
+ )}
458
  </div>
459
  <div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
460
  <div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
 
467
  <div className="space-y-12">
468
  <div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20 shadow-2xl shadow-purple-500/5 relative overflow-hidden group">
469
  <div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/20 blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity" />
470
+ <h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-purple-400 mb-6">Timeline</h4>
471
+
472
+ <div className="space-y-4">
473
+ <div>
474
+ <div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Closing Deadline</div>
475
+ <div className="text-2xl font-black text-white font-mono">
476
+ {selectedTenderForModal.closing_date ? new Date(selectedTenderForModal.closing_date).toLocaleDateString() : "---"}
477
+ </div>
478
+ </div>
479
+
480
+ {selectedTenderForModal.publication_date && (
481
+ <div>
482
+ <div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Published On</div>
483
+ <div className="text-sm font-bold text-slate-300">
484
+ {new Date(selectedTenderForModal.publication_date).toLocaleDateString()}
485
+ </div>
486
+ </div>
487
+ )}
488
  </div>
489
+
490
+ <p className="mt-6 text-[10px] text-purple-400/60 font-bold uppercase tracking-tighter border-t border-purple-400/10 pt-4">Final Window for Submission</p>
491
  </div>
492
 
493
  <div>
frontend/lib/types.ts CHANGED
@@ -1,5 +1,9 @@
1
  export type TenderItem = {
 
 
 
2
  name: string;
 
3
  quantity: number;
4
  unit: string;
5
  };
@@ -12,10 +16,15 @@ export type TenderAttachment = {
12
  export type Tender = {
13
  code: string;
14
  name: string;
 
15
  buyer: string;
 
16
  status: string;
17
- closing_date: string;
18
- description: string;
 
 
 
19
  estimated_amount: number | null;
20
  source: string;
21
  region?: string;
 
1
  export type TenderItem = {
2
+ correlative?: number;
3
+ product_code?: string;
4
+ category?: string;
5
  name: string;
6
+ description?: string;
7
  quantity: number;
8
  unit: string;
9
  };
 
16
  export type Tender = {
17
  code: string;
18
  name: string;
19
+ description: string;
20
  buyer: string;
21
+ buyer_region?: string;
22
  status: string;
23
+ status_code?: string;
24
+ type?: string;
25
+ currency?: string;
26
+ closing_date: string | null;
27
+ publication_date?: string | null;
28
  estimated_amount: number | null;
29
  source: string;
30
  region?: string;