Álvaro Valenzuela Valdes commited on
Commit
c5a34a3
·
1 Parent(s): 3795cc2

fix: Restore credentials from screenshot and finalize Mercado Público integration

Browse files
backend/app/config.py CHANGED
@@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
2
 
3
 
4
  class Settings(BaseSettings):
5
- mercado_publico_ticket: str | None = None
6
  gemini_api_key: str | None = None
7
  gemini_model: str = "gemini-2.5-flash"
8
  featherless_api_key: str | None = None
 
2
 
3
 
4
  class Settings(BaseSettings):
5
+ mercado_publico_ticket: str | None = "99B4CA8C-C1DF-4E3F-B5CF-C1672D432A91"
6
  gemini_api_key: str | None = None
7
  gemini_model: str = "gemini-2.5-flash"
8
  featherless_api_key: str | None = None
backend/app/services/mercado_publico.py CHANGED
@@ -1,243 +1,96 @@
1
- import json
2
- from pathlib import Path
3
- from typing import Any, List, Optional
4
- from datetime import datetime
5
-
6
  import httpx
7
-
8
  from app.config import settings
9
- from app.schemas.tender import Tender, TenderItem, TenderAttachment
10
-
11
- # No mock data paths allowed
12
-
13
- # API Mappings from official documentation
14
- TENDER_TYPES = {
15
- "L1": "Licitación Pública < 100 UTM",
16
- "LE": "Licitación Pública 100-1000 UTM",
17
- "LP": "Licitación Pública > 1000 UTM",
18
- "LS": "Licitación Pública Servicios Personales",
19
- "LR": "Licitación Pública (Regulada)",
20
- "A1": "Licitación Privada sin oferentes previos",
21
- "B1": "Licitación Privada excluida Ley de Compras",
22
- "CO": "Licitación Privada 100-1000 UTM",
23
- "B2": "Licitación Privada > 1000 UTM",
24
- "D1": "Trato Directo Proveedor Único",
25
- "C2": "Trato Directo (Cotización)",
26
- "C1": "Compra Directa (Orden de compra)",
27
- "O1": "Obras Públicas",
28
- }
29
-
30
- TENDER_STATUS_CODES = {
31
- "5": "Publicada",
32
- "6": "Cerrada",
33
- "7": "Desierta",
34
- "8": "Adjudicada",
35
- "18": "Revocada",
36
- "19": "Suspendida",
37
- }
38
-
39
-
40
- def _extract_type_label(code: str) -> str:
41
- """Extract tender type from code like '1000-6-LE26' -> 'LE' -> 'Licitación Pública 100-1000 UTM'."""
42
- if not code:
43
- return ""
44
- parts = code.split("-")
45
- if len(parts) >= 3:
46
- type_code = "".join(c for c in parts[2] if c.isalpha())
47
- return TENDER_TYPES.get(type_code, type_code)
48
- return ""
49
-
50
-
51
- # Mock data loading removed for production-ready hackathon version
52
-
53
-
54
- def normalize_list_tender(raw: dict[str, Any]) -> Tender:
55
- """Normalize a tender from the LISTING endpoint (basic data only)."""
56
- code = raw.get("CodigoExterno", "")
57
- status_code = str(raw.get("CodigoEstado", ""))
58
- status_desc = TENDER_STATUS_CODES.get(status_code, f"Estado {status_code}")
59
- closing = raw.get("FechaCierre", "")
60
-
61
- return Tender(
62
- code=code,
63
- name=raw.get("Nombre", "Sin título"),
64
- buyer="", # Not available in list endpoint
65
- status=status_desc,
66
- closing_date=str(closing) if closing else "",
67
- description=raw.get("Nombre", ""),
68
- estimated_amount=None,
69
- source="Mercado Público",
70
- region=None,
71
- sector=_extract_type_label(code),
72
- items=[],
73
- attachments=[],
74
- )
75
-
76
-
77
- def normalize_detail_tender(raw: dict[str, Any]) -> Tender:
78
- """Normalize a tender from the DETAIL endpoint (full data)."""
79
- # Items
80
- items_data = raw.get("Items", {})
81
- raw_items = items_data.get("Listado", []) if isinstance(items_data, dict) else []
82
- items = []
83
- for ri in raw_items:
84
- if isinstance(ri, dict):
85
- items.append(TenderItem(
86
- name=ri.get("NombreProducto", ri.get("Producto", "Item")),
87
- quantity=float(ri.get("Cantidad", 0)),
88
- unit=ri.get("UnidadMedida", "Unidad"),
89
- ))
90
-
91
- # Attachments
92
- adj_data = raw.get("Adjuntos", [])
93
- attachments = []
94
- if isinstance(adj_data, list):
95
- for ra in adj_data:
96
- if isinstance(ra, dict):
97
- attachments.append(TenderAttachment(
98
- name=ra.get("NombreArchivo", ra.get("Nombre", "Adjunto")),
99
- url=ra.get("URL", ra.get("Url", "#")),
100
- ))
101
-
102
- code = raw.get("CodigoExterno", raw.get("Codigo", ""))
103
- status_code = str(raw.get("CodigoEstado", raw.get("Estado", "")))
104
- status_desc = TENDER_STATUS_CODES.get(status_code, f"Estado {status_code}")
105
-
106
- # Buyer info
107
- comprador = raw.get("Comprador", {})
108
- buyer_name = ""
109
- if isinstance(comprador, dict):
110
- buyer_name = comprador.get("NombreOrganismo", comprador.get("Nombre", ""))
111
- if not buyer_name:
112
- buyer_name = raw.get("Organismo", raw.get("NombreOrganismo", ""))
113
-
114
- return Tender(
115
- code=code,
116
- name=raw.get("Nombre", "Sin título"),
117
- buyer=str(buyer_name) if buyer_name else "No disponible",
118
- status=status_desc,
119
- closing_date=str(raw.get("FechaCierre", "")),
120
- description=raw.get("Descripcion", raw.get("Nombre", "")),
121
- estimated_amount=raw.get("MontoEstimado", None),
122
- source="Mercado Público",
123
- region=None,
124
- sector=_extract_type_label(code),
125
- items=items,
126
- attachments=attachments,
127
- )
128
 
 
129
 
130
- async def _api_call_list(params: dict) -> List[Tender]:
131
- """Call listing endpoint (returns basic data for many tenders)."""
 
 
132
  if not settings.mercado_publico_ticket:
133
- print("[MercadoPublico] No ticket configured")
134
  return []
135
 
136
- params["ticket"] = settings.mercado_publico_ticket
 
 
 
 
 
 
 
 
137
  try:
138
- print(f"[MercadoPublico] List request: {params}")
139
- async with httpx.AsyncClient(timeout=60) as client:
140
- url = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
141
- response = await client.get(url, params=params)
142
  response.raise_for_status()
143
- body = response.json()
144
- items = body.get("Listado", [])
145
- cantidad = body.get("Cantidad", 0)
146
- print(f"[MercadoPublico] Got {cantidad} items from API")
147
- if isinstance(items, dict):
148
- items = [items]
149
- return [normalize_list_tender(i) for i in items if isinstance(i, dict)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  except Exception as e:
151
- print(f"[MercadoPublico] List API Error: {e}")
152
  return []
153
 
154
-
155
- async def _api_call_detail(code: str) -> Optional[Tender]:
156
- """Call detail endpoint for a single tender by code (returns full data)."""
 
157
  if not settings.mercado_publico_ticket:
158
  return None
159
 
160
- params = {"codigo": code, "ticket": settings.mercado_publico_ticket}
 
 
 
 
161
  try:
162
- print(f"[MercadoPublico] Detail request for: {code}")
163
- async with httpx.AsyncClient(timeout=30) as client:
164
- url = "https://api.mercadopublico.cl/servicios/v1/publico/licitaciones.json"
165
- response = await client.get(url, params=params)
166
  response.raise_for_status()
167
- body = response.json()
168
- items = body.get("Listado", [])
169
- if isinstance(items, dict):
170
- items = [items]
171
- if items and isinstance(items[0], dict):
172
- return normalize_detail_tender(items[0])
 
 
 
 
 
 
 
 
 
 
 
 
173
  return None
174
  except Exception as e:
175
- print(f"[MercadoPublico] Detail API Error: {e}")
176
  return None
177
-
178
-
179
- async def fetch_tenders(keyword: str = None, date: str = None, state: str = "activas") -> List[Tender]:
180
- """Main search function. Searches by keyword (code or text) or by date/state."""
181
-
182
- # If keyword looks like a tender code (contains dashes and alphanumeric), search by code
183
- if keyword and "-" in keyword:
184
- result = await _api_call_detail(keyword)
185
- return [result] if result else []
186
-
187
- # Otherwise, get listing and filter client-side
188
- params: dict[str, str] = {}
189
- if state:
190
- params["estado"] = state
191
- if date:
192
- params["fecha"] = date
193
-
194
- results = await _api_call_list(params)
195
-
196
- # Client-side keyword filter (API listing doesn't support text search)
197
- if keyword:
198
- kw = keyword.lower()
199
- results = [
200
- r for r in results
201
- if kw in r.name.lower() or kw in r.code.lower()
202
- ]
203
-
204
- # Return all results to populate the local DB
205
- return results
206
-
207
-
208
- async def get_tenders_by_buyer(buyer_code: str, date: str = None) -> List[Tender]:
209
- params: dict[str, str] = {"CodigoOrganismo": buyer_code}
210
- if date:
211
- params["fecha"] = date
212
- return await _api_call_list(params)
213
-
214
-
215
- async def get_tenders_by_provider(provider_code: str, date: str = None) -> List[Tender]:
216
- params: dict[str, str] = {"CodigoProveedor": provider_code}
217
- if date:
218
- params["fecha"] = date
219
- return await _api_call_list(params)
220
-
221
-
222
- async def get_tender_by_code(code: str) -> Optional[Tender]:
223
- return await _api_call_detail(code)
224
-
225
-
226
- async def search_organizations(keyword: str = None) -> List[dict]:
227
- """Search buyer organizations using the Mercado Público directory."""
228
- if not settings.mercado_publico_ticket:
229
- return []
230
- try:
231
- async with httpx.AsyncClient(timeout=10) as client:
232
- url = "https://api.mercadopublico.cl/servicios/v1/Publico/Empresas/BuscarComprador"
233
- params = {"ticket": settings.mercado_publico_ticket}
234
- response = await client.get(url, params=params)
235
- body = response.json()
236
- items = body if isinstance(body, list) else []
237
- if keyword:
238
- kw = keyword.lower()
239
- return [i for i in items if kw in str(i.get("NombreEmpresa", "")).lower()][:50]
240
- return items[:50]
241
- except Exception as e:
242
- print(f"[MercadoPublico] Org Search Error: {e}")
243
- return []
 
 
 
 
 
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/sync.py CHANGED
@@ -6,57 +6,20 @@ import json
6
 
7
  async def sync_tenders_to_db(db: Session, keyword: str = None):
8
  """
9
- Fetches tenders from API and saves/updates them in the database.
10
- Fallback: Always injects demo software tenders.
11
  """
12
- print(f"[Sync] Starting synchronization... keyword={keyword}")
13
 
14
- # --- FALLBACK DEMO DATA ---
15
- from datetime import datetime, timedelta
16
- demo_tenders = [
17
- {
18
- "code": "2394-15-LR24",
19
- "name": "Implementación Sistema ERP para Red de Salud Oriente",
20
- "description": "Suministro, instalación y soporte de sistema de gestión de recursos empresariales para red hospitalaria.",
21
- "buyer": "Servicio de Salud Metropolitano",
22
- "status": "Publicada",
23
- "closing_date": datetime.now() + timedelta(days=20),
24
- "estimated_amount": 450000000,
25
- "region": "Metropolitana",
26
- "sector": "Tecnología de la Información",
27
- "source": "Mercado Público"
28
- },
29
- {
30
- "code": "5021-10-LP24",
31
- "name": "Plataforma de IA para Análisis de Datos Criminalísticos",
32
- "description": "Desarrollo de algoritmos de visión computacional y análisis predictivo para seguridad ciudadana.",
33
- "buyer": "Subsecretaría de Prevención del Delito",
34
- "status": "Publicada",
35
- "closing_date": datetime.now() + timedelta(days=12),
36
- "estimated_amount": 180000000,
37
- "region": "Metropolitana",
38
- "sector": "Software & IA",
39
- "source": "Mercado Público"
40
- }
41
- ]
42
-
43
- for dt in demo_tenders:
44
- exists = db.query(TenderModel).filter(TenderModel.code == dt["code"]).first()
45
- if not exists:
46
- db.add(TenderModel(**dt))
47
- db.commit()
48
- # --------------------------
49
-
50
  try:
51
  api_tenders = await fetch_tenders(keyword=keyword)
52
  if not api_tenders:
53
- print("[Sync] WARNING: API returned ZERO tenders. Fallback seeds used.")
54
- return {"new": 2, "updated": 0, "message": "Demo data seeded"}
55
- else:
56
- print(f"[Sync] API returned {len(api_tenders)} tenders for processing.")
57
  except Exception as e:
58
- print(f"[Sync] API error (continuing with demo data): {e}")
59
- return {"new": 2, "updated": 0, "message": "Demo data seeded (API Error)"}
60
 
61
  count_new = 0
62
  count_updated = 0
 
6
 
7
  async def sync_tenders_to_db(db: Session, keyword: str = None):
8
  """
9
+ Fetches real tenders from Mercado Público API and saves them.
 
10
  """
11
+ print(f"[Sync] Starting REAL synchronization... keyword={keyword}")
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  try:
14
  api_tenders = await fetch_tenders(keyword=keyword)
15
  if not api_tenders:
16
+ print("[Sync] No active tenders found for today in the API.")
17
+ return {"new": 0, "updated": 0, "message": "No new tenders found"}
18
+
19
+ print(f"[Sync] API returned {len(api_tenders)} real tenders for processing.")
20
  except Exception as e:
21
+ print(f"[Sync] API error: {e}")
22
+ return {"new": 0, "updated": 0, "message": f"API Error: {str(e)}"}
23
 
24
  count_new = 0
25
  count_updated = 0