Á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 +1 -0
- backend/app/routers/company.py +5 -2
- backend/app/routers/tenders.py +38 -0
- backend/app/schemas/company.py +1 -0
- frontend/components/CompanyProfile.tsx +13 -0
- frontend/components/Dashboard.tsx +30 -9
- frontend/lib/api.ts +6 -0
- frontend/lib/types.ts +1 -0
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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
<div className="space-y-3">
|
| 300 |
-
{tenders.length > 0 ? (
|
| 301 |
-
tenders.slice(0,
|
| 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-
|
| 336 |
-
<
|
|
|
|
|
|
|
|
|
|
| 337 |
<button
|
| 338 |
onClick={handleGlobalSync}
|
| 339 |
-
className="
|
| 340 |
>
|
| 341 |
-
📥
|
| 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 = {
|