Álvaro Valenzuela Valdes commited on
Commit
8663682
·
1 Parent(s): 8cbe7e8

feat: implement IA recommendation engine based on company keywords

Browse files
backend/app/models/company.py CHANGED
@@ -12,3 +12,4 @@ class CompanyProfileModel(Base):
12
  certifications = Column(Text)
13
  regions = Column(Text)
14
  documents_available = Column(Text)
 
 
12
  certifications = Column(Text)
13
  regions = Column(Text)
14
  documents_available = Column(Text)
15
+ keywords = Column(Text) # Comma separated keywords for recommendations
backend/app/routers/company.py CHANGED
@@ -25,6 +25,7 @@ def save_company_profile(profile: CompanyProfile, db: Session = Depends(get_db))
25
  db_profile.certifications = json.dumps(profile.certifications)
26
  db_profile.regions = json.dumps(profile.regions)
27
  db_profile.documents_available = json.dumps(profile.documents_available)
 
28
 
29
  db.commit()
30
  print("!!! PROFILE SAVED SUCCESSFULLY !!!")
@@ -42,7 +43,8 @@ def get_company_profile(db: Session = Depends(get_db)):
42
  experience="5 años en el sector",
43
  certifications=[],
44
  regions=["Metropolitana"],
45
- documents_available=["RUT"]
 
46
  )
47
 
48
  # Handle list fields that are stored as JSON strings
@@ -59,5 +61,6 @@ def get_company_profile(db: Session = Depends(get_db)):
59
  experience=db_profile.experience,
60
  certifications=safe_json_load(db_profile.certifications),
61
  regions=safe_json_load(db_profile.regions, ["Nacional"]),
62
- documents_available=safe_json_load(db_profile.documents_available)
 
63
  )
 
25
  db_profile.certifications = json.dumps(profile.certifications)
26
  db_profile.regions = json.dumps(profile.regions)
27
  db_profile.documents_available = json.dumps(profile.documents_available)
28
+ db_profile.keywords = json.dumps(profile.keywords)
29
 
30
  db.commit()
31
  print("!!! PROFILE SAVED SUCCESSFULLY !!!")
 
43
  experience="5 años en el sector",
44
  certifications=[],
45
  regions=["Metropolitana"],
46
+ documents_available=["RUT"],
47
+ keywords=["software", "IA", "automatización"]
48
  )
49
 
50
  # Handle list fields that are stored as JSON strings
 
61
  experience=db_profile.experience,
62
  certifications=safe_json_load(db_profile.certifications),
63
  regions=safe_json_load(db_profile.regions, ["Nacional"]),
64
+ documents_available=safe_json_load(db_profile.documents_available),
65
+ keywords=safe_json_load(db_profile.keywords, ["tecnología"])
66
  )
backend/app/routers/tenders.py CHANGED
@@ -13,6 +13,8 @@ from app.services.mercado_publico import (
13
  get_tender_by_code,
14
  get_tenders_by_date,
15
  )
 
 
16
 
17
  router = APIRouter()
18
 
@@ -99,3 +101,39 @@ async def manual_sync(keyword: Optional[str] = None, db: Session = Depends(get_d
99
  async def live_scrape(keyword: str):
100
  from app.services.scraper import scrape_compra_agil
101
  return await scrape_compra_agil(keyword)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  get_tender_by_code,
14
  get_tenders_by_date,
15
  )
16
+ from app.models.company import CompanyProfileModel
17
+ import json
18
 
19
  router = APIRouter()
20
 
 
101
  async def live_scrape(keyword: str):
102
  from app.services.scraper import scrape_compra_agil
103
  return await scrape_compra_agil(keyword)
104
+
105
+ @router.get("/tenders/recommendations", response_model=List[Tender])
106
+ async def get_recommended_tenders(db: Session = Depends(get_db)):
107
+ """Busca licitaciones locales que coincidan con las keywords del perfil de empresa."""
108
+ profile = db.query(CompanyProfileModel).first()
109
+ if not profile or not profile.keywords:
110
+ # Fallback if no profile: return newest 5
111
+ return db.query(TenderModel).order_by(TenderModel.closing_date.asc()).limit(5).all()
112
+
113
+ try:
114
+ keywords = json.loads(profile.keywords)
115
+ except:
116
+ keywords = [profile.keywords] if profile.keywords else []
117
+
118
+ if not keywords:
119
+ return db.query(TenderModel).order_by(TenderModel.closing_date.asc()).limit(5).all()
120
+
121
+ # Construir búsqueda por keywords
122
+ filters = []
123
+ for kw in keywords:
124
+ if len(kw) < 3: continue
125
+ filters.append(TenderModel.name.ilike(f"%{kw}%"))
126
+ filters.append(TenderModel.description.ilike(f"%{kw}%"))
127
+ filters.append(TenderModel.sector.ilike(f"%{kw}%"))
128
+
129
+ if not filters:
130
+ return db.query(TenderModel).order_by(TenderModel.closing_date.asc()).limit(5).all()
131
+
132
+ recommended = db.query(TenderModel).filter(or_(*filters)).order_by(TenderModel.closing_date.asc()).limit(10).all()
133
+
134
+ # If not enough results, add some newest ones to fill
135
+ if len(recommended) < 5:
136
+ more = db.query(TenderModel).order_by(TenderModel.closing_date.asc()).limit(5 - len(recommended)).all()
137
+ recommended.extend(more)
138
+
139
+ return recommended
backend/app/schemas/company.py CHANGED
@@ -10,3 +10,4 @@ class CompanyProfile(BaseModel):
10
  certifications: List[str]
11
  regions: List[str]
12
  documents_available: List[str]
 
 
10
  certifications: List[str]
11
  regions: List[str]
12
  documents_available: List[str]
13
+ keywords: List[str] = []
frontend/components/CompanyProfile.tsx CHANGED
@@ -16,6 +16,7 @@ export default function CompanyProfile({ profile, onSave }: Props) {
16
  const [certsStr, setCertsStr] = useState(profile.certifications?.join(", ") || "");
17
  const [regionsStr, setRegionsStr] = useState(profile.regions?.join(", ") || "");
18
  const [docsStr, setDocsStr] = useState(profile.documents_available?.join(", ") || "");
 
19
 
20
  const [saving, setSaving] = useState(false);
21
  const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "success" | "error">("idle");
@@ -31,6 +32,7 @@ export default function CompanyProfile({ profile, onSave }: Props) {
31
  certifications: certsStr.split(",").map((s) => s.trim()).filter(Boolean),
32
  regions: regionsStr.split(",").map((s) => s.trim()).filter(Boolean),
33
  documents_available: docsStr.split(",").map((s) => s.trim()).filter(Boolean),
 
34
  };
35
 
36
  console.log("[CompanyProfile] Sending to onSave:", updatedProfile);
@@ -119,6 +121,17 @@ export default function CompanyProfile({ profile, onSave }: Props) {
119
  />
120
  </label>
121
 
 
 
 
 
 
 
 
 
 
 
 
122
  <div className="flex justify-center pt-8 pb-4">
123
  <button
124
  type="button"
 
16
  const [certsStr, setCertsStr] = useState(profile.certifications?.join(", ") || "");
17
  const [regionsStr, setRegionsStr] = useState(profile.regions?.join(", ") || "");
18
  const [docsStr, setDocsStr] = useState(profile.documents_available?.join(", ") || "");
19
+ const [keywordsStr, setKeywordsStr] = useState(profile.keywords?.join(", ") || "");
20
 
21
  const [saving, setSaving] = useState(false);
22
  const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "success" | "error">("idle");
 
32
  certifications: certsStr.split(",").map((s) => s.trim()).filter(Boolean),
33
  regions: regionsStr.split(",").map((s) => s.trim()).filter(Boolean),
34
  documents_available: docsStr.split(",").map((s) => s.trim()).filter(Boolean),
35
+ keywords: keywordsStr.split(",").map((s) => s.trim()).filter(Boolean),
36
  };
37
 
38
  console.log("[CompanyProfile] Sending to onSave:", updatedProfile);
 
121
  />
122
  </label>
123
 
124
+ <label className="block rounded-3xl border border-slate-800 bg-slate-950/80 p-5">
125
+ <span className="text-sm text-slate-400">Keywords (for recommendations)</span>
126
+ <input
127
+ value={keywordsStr}
128
+ onChange={(event) => setKeywordsStr(event.target.value)}
129
+ placeholder="software, AI, development, consulting"
130
+ className="mt-3 w-full rounded-2xl border border-indigo-500/30 bg-slate-900 px-4 py-3 text-white outline-none focus:border-indigo-500 transition-all shadow-[0_0_15px_rgba(99,102,241,0.05)]"
131
+ />
132
+ <p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest">Separate with commas. These determine your Dashboard recommendations.</p>
133
+ </label>
134
+
135
  <div className="flex justify-center pt-8 pb-4">
136
  <button
137
  type="button"
frontend/components/Dashboard.tsx CHANGED
@@ -2,7 +2,7 @@ import StatCard from "./StatCard";
2
  import { Tender } from "../lib/types";
3
  import { useEffect, useMemo, useState } from "react";
4
  import BrandLoader from "./BrandLoader";
5
- import { searchTenders, fetchDbStatus, syncDatabase } from "../lib/api";
6
 
7
  import { translations, Language } from "../lib/translations";
8
 
@@ -32,6 +32,18 @@ export default function Dashboard({
32
  const t = translations[lang];
33
  const [isSyncing, setIsSyncing] = useState(false);
34
  const [dbStatus, setDbStatus] = useState<any>(null);
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  useEffect(() => {
37
  async function loadStatus() {
@@ -294,11 +306,17 @@ export default function Dashboard({
294
  </div>
295
  </div>
296
 
297
- <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6">
298
- <h3 className="text-sm uppercase tracking-widest text-slate-400 mb-6 font-semibold">Actividad Reciente en Pipeline</h3>
 
 
 
 
 
 
299
  <div className="space-y-3">
300
- {tenders.length > 0 ? (
301
- tenders.slice(0, 5).map((t) => (
302
  // ... existing map logic ...
303
  <div
304
  key={t.code}
@@ -332,13 +350,16 @@ export default function Dashboard({
332
  </div>
333
  ))
334
  ) : (
335
- <div className="flex flex-col items-center justify-center py-10 text-center">
336
- <p className="text-slate-500 text-sm italic mb-4">La base de datos local está vacía.</p>
 
 
 
337
  <button
338
  onClick={handleGlobalSync}
339
- className="text-xs font-bold text-cyan border border-cyan/20 px-4 py-2 rounded-xl hover:bg-cyan/10 transition"
340
  >
341
- 📥 Sincronizar Data Real Ahora
342
  </button>
343
  </div>
344
  )}
 
2
  import { Tender } from "../lib/types";
3
  import { useEffect, useMemo, useState } from "react";
4
  import BrandLoader from "./BrandLoader";
5
+ import { searchTenders, fetchDbStatus, syncDatabase, fetchRecommendations } from "../lib/api";
6
 
7
  import { translations, Language } from "../lib/translations";
8
 
 
32
  const t = translations[lang];
33
  const [isSyncing, setIsSyncing] = useState(false);
34
  const [dbStatus, setDbStatus] = useState<any>(null);
35
+ const [recommendations, setRecommendations] = useState<Tender[]>([]);
36
+ const [loadingRecs, setLoadingRecs] = useState(true);
37
+
38
+ useEffect(() => {
39
+ async function loadRecs() {
40
+ setLoadingRecs(true);
41
+ const recs = await fetchRecommendations();
42
+ setRecommendations(recs);
43
+ setLoadingRecs(false);
44
+ }
45
+ loadRecs();
46
+ }, []);
47
 
48
  useEffect(() => {
49
  async function loadStatus() {
 
306
  </div>
307
  </div>
308
 
309
+ <div className="rounded-3xl border border-slate-800 bg-slate-950/80 p-6 relative overflow-hidden group">
310
+ <div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-20 transition-opacity">
311
+ <span className="text-4xl">🤖</span>
312
+ </div>
313
+ <h3 className="text-sm uppercase tracking-widest text-indigo-400 mb-6 font-black flex items-center gap-2">
314
+ <span className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" />
315
+ IA Recommendations for your Company
316
+ </h3>
317
  <div className="space-y-3">
318
+ {(tenders.length > 0 || recommendations.length > 0) ? (
319
+ (tenders.length > 0 ? tenders : recommendations).slice(0, 6).map((t) => (
320
  // ... existing map logic ...
321
  <div
322
  key={t.code}
 
350
  </div>
351
  ))
352
  ) : (
353
+ <div className="flex flex-col items-center justify-center py-20 text-center">
354
+ <div className="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center mb-4 text-2xl">📡</div>
355
+ <p className="text-slate-500 text-sm italic mb-6 max-w-xs">
356
+ No local data found yet. Sync with Mercado Público to feed the Intelligence Pipeline.
357
+ </p>
358
  <button
359
  onClick={handleGlobalSync}
360
+ className="group relative px-8 py-3 rounded-2xl bg-cyan text-slate-950 font-bold hover:bg-sky transition-all active:scale-95 flex items-center gap-2"
361
  >
362
+ <span>📥 Sync Real Data Now</span>
363
  </button>
364
  </div>
365
  )}
frontend/lib/api.ts CHANGED
@@ -189,6 +189,12 @@ export async function fetchDetailedDbStats() {
189
  return res.json();
190
  }
191
 
 
 
 
 
 
 
192
  export async function scrapeTenders(keyword: string): Promise<Tender[]> {
193
  const res = await fetch(`${API_BASE}/api/tenders/scrape?keyword=${encodeURIComponent(keyword)}`);
194
  if (!res.ok) {
 
189
  return res.json();
190
  }
191
 
192
+ export async function fetchRecommendations() {
193
+ const res = await fetch(`${API_BASE}/api/tenders/recommendations`);
194
+ if (!res.ok) return [];
195
+ return res.json();
196
+ }
197
+
198
  export async function scrapeTenders(keyword: string): Promise<Tender[]> {
199
  const res = await fetch(`${API_BASE}/api/tenders/scrape?keyword=${encodeURIComponent(keyword)}`);
200
  if (!res.ok) {
frontend/lib/types.ts CHANGED
@@ -45,6 +45,7 @@ export type CompanyProfile = {
45
  certifications: string[];
46
  regions: string[];
47
  documents_available: string[];
 
48
  };
49
 
50
  export type RiskItem = {
 
45
  certifications: string[];
46
  regions: string[];
47
  documents_available: string[];
48
+ keywords: string[];
49
  };
50
 
51
  export type RiskItem = {