Á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 +2 -1
- backend/app/models/tender.py +2 -0
- backend/app/models/tender_detail.py +31 -0
- backend/app/routers/analysis.py +1 -1
- backend/app/routers/oc.py +19 -4
- backend/app/routers/tender_details.py +80 -0
- backend/app/schemas/analysis.py +1 -0
- backend/app/schemas/tender.py +12 -0
- backend/app/services/agents.py +13 -7
- backend/app/services/mercado_publico.py +6 -3
- backend/app/services/mercado_publico_oc.py +22 -0
- backend/app/services/sync.py +59 -0
- backend/app/services/tender_detail_extractor.py +112 -0
- frontend/app/page.tsx +2 -2
- frontend/components/AgentAnalysis.tsx +270 -158
- frontend/components/TenderSearch.tsx +1 -1
- frontend/lib/api.ts +29 -3
- frontend/lib/types.ts +22 -0
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 |
-
#
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 158 |
-
status_code=
|
| 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/
|
| 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 |
+
};
|