feat(zopk): Panel admina bazy wiedzy, poprawa odpowiedzi AI, timeline

Priorytet 1 - Panel admina bazy wiedzy ZOPK:
- /admin/zopk/knowledge - dashboard ze statystykami
- /admin/zopk/knowledge/chunks - lista chunks z filtrowaniem
- /admin/zopk/knowledge/facts - lista faktów z typami
- /admin/zopk/knowledge/entities - lista encji z mentions
- CRUD operacje: weryfikacja, usuwanie

Priorytet 2 - Poprawa jakości odpowiedzi NordaGPT:
- Linki markdown do źródeł w kontekście ZOPK
- Ulepszone formatowanie (bold, listy, nagłówki)
- Sekcja "Źródła" na końcu odpowiedzi
- Instrukcje w system prompt dla lepszej prezentacji

Priorytet 3 - Timeline ZOPK:
- Model ZOPKMilestone w database.py
- Migracja 016_zopk_milestones.sql z sample data
- Sekcja "Roadmapa ZOPK" na stronie /zopk
- Pionowa oś czasu z markerami lat
- Statusy: completed, in_progress, planned, delayed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-17 08:56:55 +01:00
parent 7cc5f033fe
commit d0dda10bd7
11 changed files with 3270 additions and 16 deletions

302
app.py
View File

@ -9989,9 +9989,9 @@ def release_notes():
def zopk_index(): def zopk_index():
""" """
Public knowledge base page for ZOPK. Public knowledge base page for ZOPK.
Shows projects, stakeholders, approved news, and resources. Shows projects, stakeholders, approved news, resources, and timeline.
""" """
from database import ZOPKProject, ZOPKStakeholder, ZOPKNews, ZOPKResource from database import ZOPKProject, ZOPKStakeholder, ZOPKNews, ZOPKResource, ZOPKMilestone
db = SessionLocal() db = SessionLocal()
try: try:
@ -10000,6 +10000,11 @@ def zopk_index():
ZOPKProject.is_active == True ZOPKProject.is_active == True
).order_by(ZOPKProject.sort_order, ZOPKProject.name).all() ).order_by(ZOPKProject.sort_order, ZOPKProject.name).all()
# Get milestones for timeline (sorted by target_date)
milestones = db.query(ZOPKMilestone).filter(
ZOPKMilestone.is_verified == True # Only show verified milestones
).order_by(ZOPKMilestone.target_date.asc()).all()
# Get active stakeholders # Get active stakeholders
stakeholders = db.query(ZOPKStakeholder).filter( stakeholders = db.query(ZOPKStakeholder).filter(
ZOPKStakeholder.is_active == True ZOPKStakeholder.is_active == True
@ -10056,7 +10061,8 @@ def zopk_index():
news_items=news_items, news_items=news_items,
resources=resources, resources=resources,
stats=stats, stats=stats,
news_stats=news_stats news_stats=news_stats,
milestones=milestones
) )
finally: finally:
@ -11514,6 +11520,296 @@ def api_zopk_knowledge_search():
db.close() db.close()
# ============================================================
# ZOPK KNOWLEDGE BASE - ADMIN PANEL
# ============================================================
@app.route('/admin/zopk/knowledge')
@login_required
def admin_zopk_knowledge_dashboard():
"""
Dashboard for ZOPK Knowledge Base management.
Shows stats and links to chunks, facts, entities lists.
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej sekcji.', 'warning')
return redirect(url_for('index'))
return render_template('admin/zopk_knowledge_dashboard.html')
@app.route('/admin/zopk/knowledge/chunks')
@login_required
def admin_zopk_knowledge_chunks():
"""
List knowledge chunks with pagination and filtering.
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej sekcji.', 'warning')
return redirect(url_for('index'))
from zopk_knowledge_service import list_chunks
# Get query params
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
source_news_id = request.args.get('source_news_id', type=int)
has_embedding = request.args.get('has_embedding')
is_verified = request.args.get('is_verified')
# Convert string params to bool
if has_embedding is not None:
has_embedding = has_embedding.lower() == 'true'
if is_verified is not None:
is_verified = is_verified.lower() == 'true'
db = SessionLocal()
try:
result = list_chunks(
db,
page=page,
per_page=per_page,
source_news_id=source_news_id,
has_embedding=has_embedding,
is_verified=is_verified
)
return render_template(
'admin/zopk_knowledge_chunks.html',
chunks=result['chunks'],
total=result['total'],
page=result['page'],
per_page=result['per_page'],
pages=result['pages'],
source_news_id=source_news_id,
has_embedding=has_embedding,
is_verified=is_verified
)
finally:
db.close()
@app.route('/admin/zopk/knowledge/facts')
@login_required
def admin_zopk_knowledge_facts():
"""
List knowledge facts with pagination and filtering.
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej sekcji.', 'warning')
return redirect(url_for('index'))
from zopk_knowledge_service import list_facts
# Get query params
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
fact_type = request.args.get('fact_type')
source_news_id = request.args.get('source_news_id', type=int)
is_verified = request.args.get('is_verified')
# Convert string param to bool
if is_verified is not None:
is_verified = is_verified.lower() == 'true'
db = SessionLocal()
try:
result = list_facts(
db,
page=page,
per_page=per_page,
fact_type=fact_type,
is_verified=is_verified,
source_news_id=source_news_id
)
return render_template(
'admin/zopk_knowledge_facts.html',
facts=result['facts'],
total=result['total'],
page=result['page'],
per_page=result['per_page'],
pages=result['pages'],
fact_types=result['fact_types'],
current_fact_type=fact_type,
source_news_id=source_news_id,
is_verified=is_verified
)
finally:
db.close()
@app.route('/admin/zopk/knowledge/entities')
@login_required
def admin_zopk_knowledge_entities():
"""
List knowledge entities with pagination and filtering.
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej sekcji.', 'warning')
return redirect(url_for('index'))
from zopk_knowledge_service import list_entities
# Get query params
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
entity_type = request.args.get('entity_type')
min_mentions = request.args.get('min_mentions', type=int)
is_verified = request.args.get('is_verified')
# Convert string param to bool
if is_verified is not None:
is_verified = is_verified.lower() == 'true'
db = SessionLocal()
try:
result = list_entities(
db,
page=page,
per_page=per_page,
entity_type=entity_type,
is_verified=is_verified,
min_mentions=min_mentions
)
return render_template(
'admin/zopk_knowledge_entities.html',
entities=result['entities'],
total=result['total'],
page=result['page'],
per_page=result['per_page'],
pages=result['pages'],
entity_types=result['entity_types'],
current_entity_type=entity_type,
min_mentions=min_mentions,
is_verified=is_verified
)
finally:
db.close()
@app.route('/api/zopk/knowledge/chunks/<int:chunk_id>')
@login_required
def api_zopk_chunk_detail(chunk_id):
"""Get detailed information about a chunk."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_knowledge_service import get_chunk_detail
db = SessionLocal()
try:
chunk = get_chunk_detail(db, chunk_id)
if not chunk:
return jsonify({'success': False, 'error': 'Chunk nie znaleziony'}), 404
return jsonify({'success': True, 'chunk': chunk})
finally:
db.close()
@app.route('/api/zopk/knowledge/chunks/<int:chunk_id>/verify', methods=['POST'])
@login_required
def api_zopk_chunk_verify(chunk_id):
"""Toggle chunk verification status."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_knowledge_service import update_chunk_verification
db = SessionLocal()
try:
data = request.get_json() or {}
is_verified = data.get('is_verified', True)
success = update_chunk_verification(db, chunk_id, is_verified, current_user.id)
if not success:
return jsonify({'success': False, 'error': 'Chunk nie znaleziony'}), 404
return jsonify({'success': True, 'is_verified': is_verified})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/api/zopk/knowledge/facts/<int:fact_id>/verify', methods=['POST'])
@login_required
def api_zopk_fact_verify(fact_id):
"""Toggle fact verification status."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_knowledge_service import update_fact_verification
db = SessionLocal()
try:
data = request.get_json() or {}
is_verified = data.get('is_verified', True)
success = update_fact_verification(db, fact_id, is_verified)
if not success:
return jsonify({'success': False, 'error': 'Fakt nie znaleziony'}), 404
return jsonify({'success': True, 'is_verified': is_verified})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/api/zopk/knowledge/entities/<int:entity_id>/verify', methods=['POST'])
@login_required
def api_zopk_entity_verify(entity_id):
"""Toggle entity verification status."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_knowledge_service import update_entity_verification
db = SessionLocal()
try:
data = request.get_json() or {}
is_verified = data.get('is_verified', True)
success = update_entity_verification(db, entity_id, is_verified)
if not success:
return jsonify({'success': False, 'error': 'Encja nie znaleziona'}), 404
return jsonify({'success': True, 'is_verified': is_verified})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/api/zopk/knowledge/chunks/<int:chunk_id>', methods=['DELETE'])
@login_required
def api_zopk_chunk_delete(chunk_id):
"""Delete a chunk and its associated data."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from zopk_knowledge_service import delete_chunk
db = SessionLocal()
try:
success = delete_chunk(db, chunk_id)
if not success:
return jsonify({'success': False, 'error': 'Chunk nie znaleziony'}), 404
return jsonify({'success': True, 'message': 'Chunk usunięty'})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================ # ============================================================
# KRS AUDIT (Krajowy Rejestr Sądowy) # KRS AUDIT (Krajowy Rejestr Sądowy)
# ============================================================ # ============================================================

View File

@ -1841,6 +1841,62 @@ class ZOPKStakeholderProject(Base):
) )
class ZOPKMilestone(Base):
"""
Timeline milestones for ZOPK projects.
Tracks key events: announcements, decisions, construction, completions.
Used for public timeline visualization on /zopk page.
"""
__tablename__ = 'zopk_milestones'
id = Column(Integer, primary_key=True)
# Basic info
title = Column(String(255), nullable=False)
description = Column(Text)
# Categorization
# Types: announcement, decision, construction_start, construction_progress,
# completion, investment, agreement, regulation
milestone_type = Column(String(50), nullable=False, default='announcement')
# Project association
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='SET NULL'))
# Timeline
target_date = Column(Date) # Planned/expected date
actual_date = Column(Date) # Actual completion date (if completed)
date_precision = Column(String(20), default='exact') # exact, month, quarter, year
# Status: planned, in_progress, completed, delayed, cancelled
status = Column(String(20), nullable=False, default='planned')
# Source linking
source_news_id = Column(Integer, ForeignKey('zopk_news.id', ondelete='SET NULL'))
source_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id', ondelete='SET NULL'))
source_url = Column(String(1000))
# Display settings
icon = Column(String(50), default='📌')
color = Column(String(7), default='#059669')
is_featured = Column(Boolean, default=False)
display_order = Column(Integer, default=0)
# Verification
is_verified = Column(Boolean, default=False)
verified_by = Column(Integer, ForeignKey('users.id'))
verified_at = Column(DateTime)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project = relationship('ZOPKProject', backref='milestones')
# Note: source_news relationship defined in ZOPKNews class via backref
verifier = relationship('User', foreign_keys=[verified_by])
class ZOPKNews(Base): class ZOPKNews(Base):
""" """
News articles about ZOPK with approval workflow. News articles about ZOPK with approval workflow.

View File

@ -0,0 +1,75 @@
-- ============================================================
-- MIGRATION 016: ZOPK Milestones (Timeline)
-- ============================================================
-- Created: 2026-01-17
-- Purpose: Add milestones table for ZOPK timeline visualization
-- ============================================================
-- Milestones table for timeline
CREATE TABLE IF NOT EXISTS zopk_milestones (
id SERIAL PRIMARY KEY,
-- Basic info
title VARCHAR(255) NOT NULL,
description TEXT,
-- Categorization
milestone_type VARCHAR(50) NOT NULL DEFAULT 'announcement',
-- Types: announcement, decision, construction_start, construction_progress,
-- completion, investment, agreement, regulation
-- Project association (optional)
project_id INTEGER REFERENCES zopk_projects(id) ON DELETE SET NULL,
-- Timeline
target_date DATE, -- Planned/expected date
actual_date DATE, -- Actual completion date (if completed)
date_precision VARCHAR(20) DEFAULT 'exact', -- exact, month, quarter, year
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'planned',
-- Status: planned, in_progress, completed, delayed, cancelled
-- Source linking
source_news_id INTEGER REFERENCES zopk_news(id) ON DELETE SET NULL,
source_fact_id INTEGER REFERENCES zopk_knowledge_facts(id) ON DELETE SET NULL,
source_url VARCHAR(1000), -- External source URL
-- Display settings
icon VARCHAR(50) DEFAULT '📌',
color VARCHAR(7) DEFAULT '#059669', -- HEX color
is_featured BOOLEAN DEFAULT FALSE,
display_order INTEGER DEFAULT 0,
-- Verification
is_verified BOOLEAN DEFAULT FALSE,
verified_by INTEGER REFERENCES users(id),
verified_at TIMESTAMP,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_milestones_project ON zopk_milestones(project_id);
CREATE INDEX IF NOT EXISTS idx_milestones_target_date ON zopk_milestones(target_date);
CREATE INDEX IF NOT EXISTS idx_milestones_status ON zopk_milestones(status);
CREATE INDEX IF NOT EXISTS idx_milestones_type ON zopk_milestones(milestone_type);
CREATE INDEX IF NOT EXISTS idx_milestones_featured ON zopk_milestones(is_featured) WHERE is_featured = TRUE;
-- Grant permissions
GRANT ALL ON TABLE zopk_milestones TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_milestones_id_seq TO nordabiz_app;
-- Insert sample milestones from known ZOPK events (all verified for display)
INSERT INTO zopk_milestones (title, description, milestone_type, target_date, status, icon, color, is_featured, is_verified, display_order) VALUES
('Podpisanie porozumienia MON-Kongsberg', 'Podpisanie porozumienia o współpracy między MON a Kongsberg Defence & Aerospace w zakresie budowy fabryki w Rumi', 'agreement', '2024-03-15', 'completed', '📝', '#059669', TRUE, TRUE, 10),
('Pozwolenie środowiskowe Baltic Power', 'Uzyskanie pozwolenia środowiskowego dla morskiej farmy wiatrowej Baltic Power', 'regulation', '2025-06-15', 'completed', '📋', '#10b981', FALSE, TRUE, 40),
('Rozpoczęcie budowy fabryki Kongsberg w Rumi', 'Start prac budowlanych zakładu produkcji dronów morskich w Rumi Invest Park', 'construction_start', '2025-09-01', 'in_progress', '🏗️', '#f59e0b', TRUE, TRUE, 30),
('Decyzja lokalizacyjna elektrowni jądrowej', 'Wydanie decyzji lokalizacyjnej dla elektrowni jądrowej w Lubiatowie-Kopalino', 'decision', '2026-03-01', 'planned', '⚖️', '#3b82f6', TRUE, TRUE, 20),
('Uruchomienie pierwszego bloku jądrowego', 'Planowane uruchomienie pierwszego bloku elektrowni jądrowej w Lubiatowie', 'completion', '2033-12-01', 'planned', '', '#8b5cf6', TRUE, TRUE, 100)
ON CONFLICT DO NOTHING;
-- Comment
COMMENT ON TABLE zopk_milestones IS 'Timeline milestones for ZOPK projects - used for public timeline visualization';

View File

@ -710,20 +710,24 @@ class NordaBizChatEngine:
).first() ).first()
if news: if news:
chunk_data['source'] = news.source_name or news.source_domain or 'nieznane' chunk_data['source'] = news.source_name or news.source_domain or 'nieznane'
chunk_data['source_url'] = news.url or ''
if news.published_at: if news.published_at:
chunk_data['date'] = news.published_at.strftime('%Y-%m-%d') chunk_data['date'] = news.published_at.strftime('%Y-%m-%d')
context['chunks'].append(chunk_data) context['chunks'].append(chunk_data)
# Get relevant facts # Get relevant facts with source information
facts = get_relevant_facts(db, query=message, limit=5) facts = get_relevant_facts(db, query=message, limit=5)
context['facts'] = [ context['facts'] = [
{ {
'fact': f['full_text'], 'fact': f['full_text'],
'type': f['fact_type'], 'type': f['fact_type'],
'confidence': f.get('confidence_score', 0), 'confidence': f.get('confidence', 0),
'value': f.get('numeric_value'), 'value': f.get('numeric_value'),
'unit': f.get('numeric_unit') 'unit': f.get('numeric_unit'),
'source_url': f.get('source_url', ''),
'source_name': f.get('source_name', ''),
'source_date': f.get('source_date', '')
} }
for f in facts for f in facts
] ]
@ -956,25 +960,53 @@ BŁĘDNIE (NIE RÓB - resetuje numerację):
system_prompt += "\n\n🌍 BAZA WIEDZY ZOPK (Zielony Okręg Przemysłowy Kaszubia):\n" system_prompt += "\n\n🌍 BAZA WIEDZY ZOPK (Zielony Okręg Przemysłowy Kaszubia):\n"
system_prompt += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" system_prompt += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
# Collect all sources for citations at the end
sources_for_citation = []
# Add knowledge chunks (most relevant excerpts) # Add knowledge chunks (most relevant excerpts)
if zopk.get('chunks'): if zopk.get('chunks'):
system_prompt += "\n📄 FRAGMENTY WIEDZY (semantycznie dopasowane):\n" system_prompt += "\n📄 FRAGMENTY WIEDZY (semantycznie dopasowane):\n"
for i, chunk in enumerate(zopk['chunks'][:5], 1): for i, chunk in enumerate(zopk['chunks'][:5], 1):
source_name = chunk.get('source', 'nieznane')
source_url = chunk.get('source_url', '')
source_date = chunk.get('date', '')
system_prompt += f"\n[{i}] {chunk.get('summary', '')}\n" system_prompt += f"\n[{i}] {chunk.get('summary', '')}\n"
system_prompt += f" Źródło: {chunk.get('source', 'nieznane')} ({chunk.get('date', '')})\n" if source_url:
system_prompt += f" Źródło: [{source_name}]({source_url}) ({source_date})\n"
if source_url and source_name:
sources_for_citation.append({
'name': source_name,
'url': source_url,
'date': source_date
})
else:
system_prompt += f" Źródło: {source_name} ({source_date})\n"
if chunk.get('content'): if chunk.get('content'):
# Skrócona treść (max 300 znaków)
content_preview = chunk['content'][:300] content_preview = chunk['content'][:300]
if len(chunk['content']) > 300: if len(chunk['content']) > 300:
content_preview += "..." content_preview += "..."
system_prompt += f" Treść: {content_preview}\n" system_prompt += f" Treść: {content_preview}\n"
# Add verified facts # Add verified facts with source links
if zopk.get('facts'): if zopk.get('facts'):
system_prompt += "\n📌 ZWERYFIKOWANE FAKTY:\n" system_prompt += "\n📌 ZWERYFIKOWANE FAKTY:\n"
for fact in zopk['facts'][:10]: for fact in zopk['facts'][:10]:
confidence_stars = "" * int(fact.get('confidence', 0) * 5) confidence_stars = "" * int(fact.get('confidence', 0) * 5)
system_prompt += f"{fact.get('fact', '')} [{confidence_stars}]\n" source_name = fact.get('source_name', '')
source_url = fact.get('source_url', '')
source_date = fact.get('source_date', '')
system_prompt += f"{fact.get('fact', '')} [{confidence_stars}]"
if source_name and source_url:
system_prompt += f" ([{source_name}]({source_url}), {source_date})"
sources_for_citation.append({
'name': source_name,
'url': source_url,
'date': source_date
})
system_prompt += "\n"
if fact.get('value') and fact.get('unit'): if fact.get('value') and fact.get('unit'):
system_prompt += f" Wartość: {fact['value']} {fact['unit']}\n" system_prompt += f" Wartość: {fact['value']} {fact['unit']}\n"
@ -987,6 +1019,7 @@ BŁĘDNIE (NIE RÓB - resetuje numerację):
'company': '🏢', 'company': '🏢',
'person': '👤', 'person': '👤',
'location': '📍', 'location': '📍',
'place': '📍',
'project': '🎯', 'project': '🎯',
'technology': '' 'technology': ''
}.get(entity.get('type', ''), '') }.get(entity.get('type', ''), '')
@ -997,12 +1030,26 @@ BŁĘDNIE (NIE RÓB - resetuje numerację):
system_prompt += f" [{entity['mentions']} wzmianek]" system_prompt += f" [{entity['mentions']} wzmianek]"
system_prompt += "\n" system_prompt += "\n"
# Add available sources for citation
if sources_for_citation:
# Deduplicate sources by URL
unique_sources = {s['url']: s for s in sources_for_citation if s.get('url')}.values()
system_prompt += "\n📚 DOSTĘPNE ŹRÓDŁA DO CYTOWANIA:\n"
for src in list(unique_sources)[:5]:
system_prompt += f"- [{src['name']}]({src['url']}) ({src['date']})\n"
system_prompt += "\n🎯 ZASADY ODPOWIEDZI O ZOPK:\n" system_prompt += "\n🎯 ZASADY ODPOWIEDZI O ZOPK:\n"
system_prompt += "1. Odpowiadaj na podstawie bazy wiedzy (nie wymyślaj faktów)\n" system_prompt += "1. Odpowiadaj na podstawie bazy wiedzy (NIE WYMYŚLAJ faktów)\n"
system_prompt += "2. Cytuj źródła: \"Według [portal] z [data]...\"\n" system_prompt += "2. FORMATUJ odpowiedzi używając:\n"
system_prompt += "3. Podawaj konkretne daty i liczby gdy dostępne\n" system_prompt += " - **Pogrubienia** dla kluczowych informacji\n"
system_prompt += "4. Wymieniaj organizacje i osoby zaangażowane\n" system_prompt += " - Listy punktowane dla wielu faktów\n"
system_prompt += "5. Jeśli brak informacji w bazie - powiedz wprost\n" system_prompt += " - Nagłówki dla sekcji (## Inwestycje, ## Terminarz)\n"
system_prompt += "3. CYTUJ źródła w tekście: \"Według [nazwa portalu](URL) z dnia RRRR-MM-DD...\"\n"
system_prompt += "4. NA KOŃCU odpowiedzi DODAJ sekcję:\n"
system_prompt += " 📚 **Źródła:**\n"
system_prompt += " - [Nazwa portalu](URL) - krótki opis (data)\n"
system_prompt += "5. Podawaj konkretne daty i liczby gdy dostępne\n"
system_prompt += "6. Jeśli brak informacji w bazie - powiedz wprost: \"Nie mam tej informacji w bazie wiedzy ZOPK\"\n"
system_prompt += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" system_prompt += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
# Add upcoming events (Etap 2) # Add upcoming events (Etap 2)

View File

@ -1342,6 +1342,13 @@
<h3 class="stats-section-title">Najczęściej wymieniane encje</h3> <h3 class="stats-section-title">Najczęściej wymieniane encje</h3>
<div id="top-entities-list" class="entity-pills"></div> <div id="top-entities-list" class="entity-pills"></div>
</div> </div>
<!-- Link to detailed dashboard -->
<div class="stats-section" style="text-align: center; padding-top: var(--spacing-lg);">
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}" class="btn btn-primary">
📊 Szczegółowy panel bazy wiedzy →
</a>
</div>
</div> </div>
<div id="knowledge-stats-error" style="display: none;" class="alert alert-danger"> <div id="knowledge-stats-error" style="display: none;" class="alert alert-danger">

View File

@ -0,0 +1,644 @@
{% extends "base.html" %}
{% block title %}Chunks - Baza Wiedzy ZOPK{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.page-header h1 {
font-size: var(--font-size-2xl);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.breadcrumb a {
color: var(--text-secondary);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
cursor: pointer;
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.table-wrapper {
overflow-x: auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
min-width: 800px;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--background);
}
.chunk-content {
max-width: 400px;
font-size: var(--font-size-sm);
line-height: 1.4;
}
.chunk-summary {
color: var(--text-secondary);
font-style: italic;
font-size: var(--font-size-xs);
margin-top: var(--spacing-xs);
}
.chunk-source {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.chunk-source a {
color: var(--primary);
text-decoration: none;
}
.chunk-source a:hover {
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-verified { background: #d1fae5; color: #065f46; }
.status-pending { background: #fef3c7; color: #92400e; }
.status-embedding { background: #dbeafe; color: #1e40af; }
.status-no-embedding { background: #fee2e2; color: #991b1b; }
.importance-stars {
color: #fbbf24;
font-size: var(--font-size-sm);
}
.action-btn {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-size: var(--font-size-xs);
transition: var(--transition);
margin-right: var(--spacing-xs);
}
.action-btn-view {
background: var(--background);
color: var(--text-primary);
}
.action-btn-view:hover {
background: var(--primary);
color: white;
}
.action-btn-verify {
background: #d1fae5;
color: #065f46;
}
.action-btn-verify:hover {
background: #065f46;
color: white;
}
.action-btn-delete {
background: #fee2e2;
color: #991b1b;
}
.action-btn-delete:hover {
background: #991b1b;
color: white;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-lg);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
}
.pagination a:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .current {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .disabled {
opacity: 0.5;
pointer-events: none;
}
.stats-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
max-width: 700px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
padding: var(--spacing-xl);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.modal-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
}
.modal-section {
margin-bottom: var(--spacing-lg);
}
.modal-section-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.modal-content-box {
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
line-height: 1.6;
}
.keyword-tag {
display: inline-block;
padding: 2px 8px;
background: var(--primary);
color: white;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
margin: 2px;
}
.fact-item, .entity-item {
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
<span></span>
<a href="{{ url_for('admin_zopk_dashboard') }}">ZOP Kaszubia</a>
<span></span>
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Chunks</span>
</div>
<div class="page-header">
<h1>📄 Chunks (Fragmenty tekstu)</h1>
</div>
<!-- Filters -->
<div class="filters">
<span style="color: var(--text-secondary); font-size: var(--font-size-sm);">Filtruj:</span>
<a href="{{ url_for('admin_zopk_knowledge_chunks') }}"
class="filter-btn {{ 'active' if has_embedding is none and is_verified is none else '' }}">
Wszystkie
</a>
<a href="{{ url_for('admin_zopk_knowledge_chunks', has_embedding='true') }}"
class="filter-btn {{ 'active' if has_embedding == true else '' }}">
✓ Z embeddingiem
</a>
<a href="{{ url_for('admin_zopk_knowledge_chunks', has_embedding='false') }}"
class="filter-btn {{ 'active' if has_embedding == false else '' }}">
✗ Bez embeddingu
</a>
<a href="{{ url_for('admin_zopk_knowledge_chunks', is_verified='true') }}"
class="filter-btn {{ 'active' if is_verified == true else '' }}">
✓ Zweryfikowane
</a>
<a href="{{ url_for('admin_zopk_knowledge_chunks', is_verified='false') }}"
class="filter-btn {{ 'active' if is_verified == false else '' }}">
⏳ Oczekujące
</a>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<span>Pokazuję {{ chunks|length }} z {{ total }} chunków (strona {{ page }} z {{ pages }})</span>
<span>
{% if source_news_id %}
Źródło: Artykuł #{{ source_news_id }}
<a href="{{ url_for('admin_zopk_knowledge_chunks') }}" style="margin-left: 8px; color: var(--primary);">✕ usuń filtr</a>
{% endif %}
</span>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Treść</th>
<th>Typ</th>
<th>Ważność</th>
<th>Status</th>
<th>Źródło</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for chunk in chunks %}
<tr id="chunk-row-{{ chunk.id }}">
<td>#{{ chunk.id }}</td>
<td class="chunk-content">
{{ chunk.content }}
{% if chunk.summary %}
<div class="chunk-summary">{{ chunk.summary }}</div>
{% endif %}
</td>
<td>
{% if chunk.chunk_type %}
<span class="status-badge">{{ chunk.chunk_type }}</span>
{% else %}
<span style="color: var(--text-tertiary);"></span>
{% endif %}
</td>
<td>
{% if chunk.importance_score %}
<span class="importance-stars">
{% for i in range(chunk.importance_score) %}★{% endfor %}{% for i in range(5 - chunk.importance_score) %}☆{% endfor %}
</span>
{% else %}
<span style="color: var(--text-tertiary);"></span>
{% endif %}
</td>
<td>
{% if chunk.is_verified %}
<span class="status-badge status-verified">✓ Zweryfikowany</span>
{% else %}
<span class="status-badge status-pending">⏳ Oczekuje</span>
{% endif %}
<br>
{% if chunk.has_embedding %}
<span class="status-badge status-embedding">🧲 Embedding</span>
{% else %}
<span class="status-badge status-no-embedding">✗ Brak</span>
{% endif %}
</td>
<td class="chunk-source">
{% if chunk.source_news_id %}
<a href="{{ url_for('admin_zopk_knowledge_chunks', source_news_id=chunk.source_news_id) }}">
Artykuł #{{ chunk.source_news_id }}
</a>
{% if chunk.source_title %}
<br>{{ chunk.source_title[:50] }}...
{% endif %}
{% endif %}
</td>
<td>
<button class="action-btn action-btn-view" onclick="viewChunk({{ chunk.id }})">👁️</button>
<button class="action-btn action-btn-verify" onclick="toggleVerify({{ chunk.id }}, {{ 'false' if chunk.is_verified else 'true' }})">
{{ '✗' if chunk.is_verified else '✓' }}
</button>
<button class="action-btn action-btn-delete" onclick="deleteChunk({{ chunk.id }})">🗑️</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="text-align: center; padding: var(--spacing-xl); color: var(--text-secondary);">
Brak chunków do wyświetlenia
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_zopk_knowledge_chunks', page=page-1, has_embedding=has_embedding, is_verified=is_verified, source_news_id=source_news_id) }}"> Poprzednia</a>
{% else %}
<span class="disabled"> Poprzednia</span>
{% endif %}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p >= pages - 2 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin_zopk_knowledge_chunks', page=p, has_embedding=has_embedding, is_verified=is_verified, source_news_id=source_news_id) }}">{{ p }}</a>
{% elif p == 4 or p == pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < pages %}
<a href="{{ url_for('admin_zopk_knowledge_chunks', page=page+1, has_embedding=has_embedding, is_verified=is_verified, source_news_id=source_news_id) }}">Następna </a>
{% else %}
<span class="disabled">Następna </span>
{% endif %}
</div>
{% endif %}
</div>
<!-- Detail Modal -->
<div class="modal-overlay" id="chunkModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">📄 Szczegóły chunka</div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div id="modalContent">
<div style="text-align: center; padding: 20px;">Ładowanie...</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
async function viewChunk(id) {
document.getElementById('chunkModal').classList.add('active');
document.getElementById('modalContent').innerHTML = '<div style="text-align: center; padding: 20px;">Ładowanie...</div>';
try {
const response = await fetch(`/api/zopk/knowledge/chunks/${id}`);
const data = await response.json();
if (data.success) {
renderChunkDetail(data.chunk);
} else {
document.getElementById('modalContent').innerHTML = `<div style="color: red;">Błąd: ${data.error}</div>`;
}
} catch (error) {
document.getElementById('modalContent').innerHTML = `<div style="color: red;">Błąd: ${error.message}</div>`;
}
}
function renderChunkDetail(chunk) {
let html = `
<div class="modal-section">
<div class="modal-section-title">Treść</div>
<div class="modal-content-box">${chunk.content}</div>
</div>
`;
if (chunk.summary) {
html += `
<div class="modal-section">
<div class="modal-section-title">Podsumowanie</div>
<div class="modal-content-box">${chunk.summary}</div>
</div>
`;
}
if (chunk.keywords && chunk.keywords.length) {
html += `
<div class="modal-section">
<div class="modal-section-title">Słowa kluczowe</div>
<div>${chunk.keywords.map(k => `<span class="keyword-tag">${k}</span>`).join('')}</div>
</div>
`;
}
if (chunk.facts && chunk.facts.length) {
html += `
<div class="modal-section">
<div class="modal-section-title">Fakty (${chunk.facts.length})</div>
${chunk.facts.map(f => `<div class="fact-item">${f.full_text}</div>`).join('')}
</div>
`;
}
if (chunk.entity_mentions && chunk.entity_mentions.length) {
html += `
<div class="modal-section">
<div class="modal-section-title">Encje (${chunk.entity_mentions.length})</div>
${chunk.entity_mentions.map(e => `<div class="entity-item">${e.entity_name} (${e.entity_type})</div>`).join('')}
</div>
`;
}
html += `
<div class="modal-section">
<div class="modal-section-title">Metadane</div>
<div class="modal-content-box">
<strong>Typ:</strong> ${chunk.chunk_type || 'brak'}<br>
<strong>Ważność:</strong> ${chunk.importance_score || 'brak'}/5<br>
<strong>Pewność:</strong> ${chunk.confidence_score ? (chunk.confidence_score * 100).toFixed(0) + '%' : 'brak'}<br>
<strong>Tokeny:</strong> ${chunk.token_count || 'brak'}<br>
<strong>Model:</strong> ${chunk.extraction_model || 'brak'}<br>
<strong>Embedding:</strong> ${chunk.has_embedding ? 'Tak' : 'Nie'}<br>
<strong>Zweryfikowany:</strong> ${chunk.is_verified ? 'Tak' : 'Nie'}
</div>
</div>
`;
if (chunk.source_news) {
html += `
<div class="modal-section">
<div class="modal-section-title">Źródło</div>
<div class="modal-content-box">
<strong>Artykuł:</strong> ${chunk.source_news.title}<br>
<strong>Portal:</strong> ${chunk.source_news.source_name || 'nieznany'}<br>
<a href="${chunk.source_news.url}" target="_blank">Otwórz źródło →</a>
</div>
</div>
`;
}
document.getElementById('modalContent').innerHTML = html;
}
function closeModal() {
document.getElementById('chunkModal').classList.remove('active');
}
document.getElementById('chunkModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
async function toggleVerify(id, newState) {
try {
const response = await fetch(`/api/zopk/knowledge/chunks/${id}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ is_verified: newState })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Błąd: ' + data.error);
}
} catch (error) {
alert('Błąd: ' + error.message);
}
}
async function deleteChunk(id) {
if (!confirm('Czy na pewno usunąć ten chunk? Zostaną również usunięte powiązane fakty i wzmianki.')) return;
try {
const response = await fetch(`/api/zopk/knowledge/chunks/${id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (data.success) {
document.getElementById('chunk-row-' + id).remove();
} else {
alert('Błąd: ' + data.error);
}
} catch (error) {
alert('Błąd: ' + error.message);
}
}
{% endblock %}

View File

@ -0,0 +1,525 @@
{% extends "base.html" %}
{% block title %}Baza Wiedzy ZOPK - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.page-header h1 {
font-size: var(--font-size-2xl);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
text-align: center;
transition: var(--transition);
text-decoration: none;
color: inherit;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-card.clickable {
cursor: pointer;
}
.stat-icon {
font-size: 2.5rem;
margin-bottom: var(--spacing-sm);
}
.stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
margin-bottom: var(--spacing-xs);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.stat-sublabel {
color: var(--text-tertiary);
font-size: var(--font-size-xs);
margin-top: var(--spacing-xs);
}
.section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.quick-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-md);
}
.quick-link {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
text-decoration: none;
color: var(--text-primary);
transition: var(--transition);
}
.quick-link:hover {
background: var(--primary);
color: white;
}
.quick-link-icon {
font-size: 1.5rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
border-radius: var(--radius);
}
.quick-link:hover .quick-link-icon {
background: rgba(255,255,255,0.2);
}
.quick-link-text {
flex: 1;
}
.quick-link-title {
font-weight: 600;
font-size: var(--font-size-base);
}
.quick-link-desc {
font-size: var(--font-size-xs);
opacity: 0.8;
}
.top-entities-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-sm);
}
.entity-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
}
.entity-type-badge {
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.entity-type-company { background: #dbeafe; color: #1e40af; }
.entity-type-person { background: #fce7f3; color: #be185d; }
.entity-type-place { background: #d1fae5; color: #065f46; }
.entity-type-organization { background: #fef3c7; color: #92400e; }
.entity-type-project { background: #e0e7ff; color: #3730a3; }
.entity-type-technology { background: #f3e8ff; color: #7c3aed; }
.entity-mentions {
margin-left: auto;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.loading::after {
content: '';
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: var(--spacing-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.action-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.action-btn-primary {
background: var(--primary);
color: white;
}
.action-btn-primary:hover {
background: var(--primary-dark);
}
.action-btn-secondary {
background: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.action-btn-secondary:hover {
background: var(--surface);
}
.pipeline-status {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.pipeline-status.running {
background: #fef3c7;
color: #92400e;
}
.pipeline-status.success {
background: #d1fae5;
color: #065f46;
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.breadcrumb a {
color: var(--text-secondary);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
<span></span>
<a href="{{ url_for('admin_zopk_dashboard') }}">ZOP Kaszubia</a>
<span></span>
<span>Baza Wiedzy</span>
</div>
<div class="page-header">
<h1>🧠 Baza Wiedzy ZOPK</h1>
<div class="header-actions">
<div id="pipelineStatus" class="pipeline-status">
<span></span>
<span>Ładowanie...</span>
</div>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid" id="statsGrid">
<div class="loading">Ładowanie statystyk...</div>
</div>
<!-- Quick Links -->
<div class="section">
<h2 class="section-title">📋 Przegląd danych</h2>
<div class="quick-links">
<a href="{{ url_for('admin_zopk_knowledge_chunks') }}" class="quick-link">
<div class="quick-link-icon">📄</div>
<div class="quick-link-text">
<div class="quick-link-title">Chunks (Fragmenty)</div>
<div class="quick-link-desc">Fragmenty tekstu z embeddingami</div>
</div>
</a>
<a href="{{ url_for('admin_zopk_knowledge_facts') }}" class="quick-link">
<div class="quick-link-icon">📌</div>
<div class="quick-link-text">
<div class="quick-link-title">Fakty</div>
<div class="quick-link-desc">Wyekstraktowane fakty strukturalne</div>
</div>
</a>
<a href="{{ url_for('admin_zopk_knowledge_entities') }}" class="quick-link">
<div class="quick-link-icon">🏢</div>
<div class="quick-link-text">
<div class="quick-link-title">Encje</div>
<div class="quick-link-desc">Firmy, osoby, miejsca, projekty</div>
</div>
</a>
<a href="{{ url_for('admin_zopk_news') }}" class="quick-link">
<div class="quick-link-icon">📰</div>
<div class="quick-link-text">
<div class="quick-link-title">Źródłowe artykuły</div>
<div class="quick-link-desc">Newsy ZOPK (źródło wiedzy)</div>
</div>
</a>
</div>
</div>
<!-- Top Entities -->
<div class="section">
<h2 class="section-title">🏆 Top 10 encji według wzmianek</h2>
<div id="topEntities" class="top-entities-grid">
<div class="loading">Ładowanie encji...</div>
</div>
</div>
<!-- Actions -->
<div class="section">
<h2 class="section-title">⚡ Akcje</h2>
<div class="quick-links">
<div class="quick-link" onclick="runExtraction()" style="cursor: pointer;">
<div class="quick-link-icon">🔄</div>
<div class="quick-link-text">
<div class="quick-link-title">Uruchom ekstrakcję</div>
<div class="quick-link-desc">Przetwórz nowe artykuły</div>
</div>
</div>
<div class="quick-link" onclick="generateEmbeddings()" style="cursor: pointer;">
<div class="quick-link-icon">🧲</div>
<div class="quick-link-text">
<div class="quick-link-title">Generuj embeddingi</div>
<div class="quick-link-desc">Wektory dla chunków bez embeddingów</div>
</div>
</div>
<a href="{{ url_for('admin_zopk_dashboard') }}" class="quick-link">
<div class="quick-link-icon">📊</div>
<div class="quick-link-text">
<div class="quick-link-title">Dashboard ZOPK</div>
<div class="quick-link-desc">Powrót do głównego panelu</div>
</div>
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
// Load stats on page load
document.addEventListener('DOMContentLoaded', function() {
loadStats();
});
async function loadStats() {
try {
const response = await fetch('/admin/zopk/knowledge/stats');
const data = await response.json();
if (data.success) {
renderStats(data);
renderTopEntities(data.top_entities || []);
updatePipelineStatus(data);
} else {
document.getElementById('statsGrid').innerHTML = '<div class="loading">Błąd ładowania: ' + data.error + '</div>';
}
} catch (error) {
console.error('Error loading stats:', error);
document.getElementById('statsGrid').innerHTML = '<div class="loading">Błąd połączenia</div>';
}
}
function renderStats(data) {
const articles = data.articles || {};
const kb = data.knowledge_base || {};
const statsHtml = `
<a href="{{ url_for('admin_zopk_knowledge_chunks') }}" class="stat-card clickable">
<div class="stat-icon">📄</div>
<div class="stat-value">${kb.total_chunks || 0}</div>
<div class="stat-label">Chunks</div>
<div class="stat-sublabel">${kb.chunks_with_embeddings || 0} z embeddingami</div>
</a>
<a href="{{ url_for('admin_zopk_knowledge_facts') }}" class="stat-card clickable">
<div class="stat-icon">📌</div>
<div class="stat-value">${kb.total_facts || 0}</div>
<div class="stat-label">Fakty</div>
<div class="stat-sublabel">Wyekstraktowane informacje</div>
</a>
<a href="{{ url_for('admin_zopk_knowledge_entities') }}" class="stat-card clickable">
<div class="stat-icon">🏢</div>
<div class="stat-value">${kb.total_entities || 0}</div>
<div class="stat-label">Encje</div>
<div class="stat-sublabel">Firmy, osoby, miejsca</div>
</a>
<div class="stat-card">
<div class="stat-icon">🔗</div>
<div class="stat-value">${kb.total_relations || 0}</div>
<div class="stat-label">Relacje</div>
<div class="stat-sublabel">Powiązania między encjami</div>
</div>
<div class="stat-card">
<div class="stat-icon">📰</div>
<div class="stat-value">${articles.extracted || 0}/${articles.scraped || 0}</div>
<div class="stat-label">Artykuły przetworzone</div>
<div class="stat-sublabel">${articles.pending_extract || 0} oczekuje</div>
</div>
<div class="stat-card">
<div class="stat-icon">🧲</div>
<div class="stat-value">${kb.chunks_with_embeddings || 0}/${kb.total_chunks || 0}</div>
<div class="stat-label">Embeddingi</div>
<div class="stat-sublabel">${kb.chunks_without_embeddings || 0} do wygenerowania</div>
</div>
`;
document.getElementById('statsGrid').innerHTML = statsHtml;
}
function renderTopEntities(entities) {
if (!entities.length) {
document.getElementById('topEntities').innerHTML = '<div class="loading">Brak encji w bazie</div>';
return;
}
const html = entities.map(e => `
<div class="entity-item">
<span class="entity-type-badge entity-type-${e.type}">${e.type}</span>
<span>${e.name}</span>
<span class="entity-mentions">${e.mentions}×</span>
</div>
`).join('');
document.getElementById('topEntities').innerHTML = html;
}
function updatePipelineStatus(data) {
const articles = data.articles || {};
const kb = data.knowledge_base || {};
let status = 'success';
let text = 'Pipeline OK';
if (articles.pending_extract > 0) {
status = 'running';
text = `${articles.pending_extract} artykułów czeka na ekstrakcję`;
} else if (kb.chunks_without_embeddings > 0) {
status = 'running';
text = `${kb.chunks_without_embeddings} chunków bez embeddingów`;
}
document.getElementById('pipelineStatus').className = 'pipeline-status ' + status;
document.getElementById('pipelineStatus').innerHTML = `
<span>${status === 'success' ? '✅' : '⏳'}</span>
<span>${text}</span>
`;
}
async function runExtraction() {
if (!confirm('Uruchomić ekstrakcję wiedzy z artykułów?')) return;
try {
const response = await fetch('/admin/zopk/knowledge/extract', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ limit: 50 })
});
const data = await response.json();
alert(data.message || data.error);
loadStats();
} catch (error) {
alert('Błąd: ' + error.message);
}
}
async function generateEmbeddings() {
if (!confirm('Wygenerować embeddingi dla chunków?')) return;
try {
const response = await fetch('/admin/zopk/knowledge/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ limit: 100 })
});
const data = await response.json();
alert(data.message || data.error);
loadStats();
} catch (error) {
alert('Błąd: ' + error.message);
}
}
{% endblock %}

View File

@ -0,0 +1,446 @@
{% extends "base.html" %}
{% block title %}Encje - Baza Wiedzy ZOPK{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.page-header h1 {
font-size: var(--font-size-2xl);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.breadcrumb a {
color: var(--text-secondary);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
cursor: pointer;
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.table-wrapper {
overflow-x: auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
min-width: 800px;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--background);
}
.entity-name {
font-weight: 600;
}
.entity-description {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
max-width: 300px;
}
.entity-aliases {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-top: var(--spacing-xs);
}
.entity-type-badge {
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.entity-type-company { background: #dbeafe; color: #1e40af; }
.entity-type-person { background: #fce7f3; color: #be185d; }
.entity-type-place { background: #d1fae5; color: #065f46; }
.entity-type-organization { background: #fef3c7; color: #92400e; }
.entity-type-project { background: #e0e7ff; color: #3730a3; }
.entity-type-technology { background: #f3e8ff; color: #7c3aed; }
.entity-type-event { background: #fce7f3; color: #be185d; }
.mentions-count {
font-weight: 600;
font-size: var(--font-size-lg);
color: var(--primary);
}
.mentions-bar {
width: 100px;
height: 6px;
background: var(--border);
border-radius: 3px;
margin-top: var(--spacing-xs);
overflow: hidden;
}
.mentions-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 3px;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-verified { background: #d1fae5; color: #065f46; }
.status-pending { background: #fef3c7; color: #92400e; }
.action-btn {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-size: var(--font-size-xs);
transition: var(--transition);
margin-right: var(--spacing-xs);
}
.action-btn-verify {
background: #d1fae5;
color: #065f46;
}
.action-btn-verify:hover {
background: #065f46;
color: white;
}
.action-btn-link {
background: #dbeafe;
color: #1e40af;
text-decoration: none;
}
.action-btn-link:hover {
background: #1e40af;
color: white;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-lg);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
}
.pagination a:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .current {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .disabled {
opacity: 0.5;
pointer-events: none;
}
.stats-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
.entity-dates {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
<span></span>
<a href="{{ url_for('admin_zopk_dashboard') }}">ZOP Kaszubia</a>
<span></span>
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Encje</span>
</div>
<div class="page-header">
<h1>🏢 Encje (rozpoznane byty)</h1>
</div>
<!-- Filters -->
<div class="filters">
<span style="color: var(--text-secondary); font-size: var(--font-size-sm);">Typ encji:</span>
<a href="{{ url_for('admin_zopk_knowledge_entities') }}"
class="filter-btn {{ 'active' if current_entity_type is none else '' }}">
Wszystkie
</a>
{% for etype in entity_types %}
<a href="{{ url_for('admin_zopk_knowledge_entities', entity_type=etype) }}"
class="filter-btn {{ 'active' if current_entity_type == etype else '' }}">
{% if etype == 'company' %}🏢{% elif etype == 'person' %}👤{% elif etype == 'place' %}📍{% elif etype == 'organization' %}🏛️{% elif etype == 'project' %}🚀{% elif etype == 'technology' %}💻{% else %}📋{% endif %}
{{ etype }}
</a>
{% endfor %}
<span style="margin-left: 20px; color: var(--text-secondary); font-size: var(--font-size-sm);">Status:</span>
<a href="{{ url_for('admin_zopk_knowledge_entities', is_verified='true', entity_type=current_entity_type) }}"
class="filter-btn {{ 'active' if is_verified == true else '' }}">
✓ Zweryfikowane
</a>
<a href="{{ url_for('admin_zopk_knowledge_entities', is_verified='false', entity_type=current_entity_type) }}"
class="filter-btn {{ 'active' if is_verified == false else '' }}">
⏳ Oczekujące
</a>
<span style="margin-left: 20px; color: var(--text-secondary); font-size: var(--font-size-sm);">Min. wzmianek:</span>
<a href="{{ url_for('admin_zopk_knowledge_entities', min_mentions=5, entity_type=current_entity_type) }}"
class="filter-btn {{ 'active' if min_mentions == 5 else '' }}">
5+
</a>
<a href="{{ url_for('admin_zopk_knowledge_entities', min_mentions=10, entity_type=current_entity_type) }}"
class="filter-btn {{ 'active' if min_mentions == 10 else '' }}">
10+
</a>
<a href="{{ url_for('admin_zopk_knowledge_entities', min_mentions=20, entity_type=current_entity_type) }}"
class="filter-btn {{ 'active' if min_mentions == 20 else '' }}">
20+
</a>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<span>Pokazuję {{ entities|length }} z {{ total }} encji (strona {{ page }} z {{ pages }})</span>
<span>Posortowane według liczby wzmianek (malejąco)</span>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Typ</th>
<th>Wzmianki</th>
<th>Status</th>
<th>Daty</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% set max_mentions = entities[0].mentions_count if entities else 1 %}
{% for entity in entities %}
<tr id="entity-row-{{ entity.id }}">
<td>#{{ entity.id }}</td>
<td>
<div class="entity-name">{{ entity.name }}</div>
{% if entity.short_description %}
<div class="entity-description">{{ entity.short_description }}</div>
{% endif %}
{% if entity.aliases and entity.aliases|length %}
<div class="entity-aliases">
Aliasy: {{ entity.aliases|join(', ') }}
</div>
{% endif %}
</td>
<td>
<span class="entity-type-badge entity-type-{{ entity.entity_type }}">
{% if entity.entity_type == 'company' %}🏢{% elif entity.entity_type == 'person' %}👤{% elif entity.entity_type == 'place' %}📍{% elif entity.entity_type == 'organization' %}🏛️{% elif entity.entity_type == 'project' %}🚀{% elif entity.entity_type == 'technology' %}💻{% else %}📋{% endif %}
{{ entity.entity_type }}
</span>
</td>
<td>
<div class="mentions-count">{{ entity.mentions_count }}</div>
<div class="mentions-bar">
<div class="mentions-bar-fill" style="width: {{ (entity.mentions_count / max_mentions * 100)|round }}%"></div>
</div>
</td>
<td>
{% if entity.is_verified %}
<span class="status-badge status-verified">✓ Zweryfikowana</span>
{% else %}
<span class="status-badge status-pending">⏳ Oczekuje</span>
{% endif %}
{% if entity.company_id %}
<br><a href="{{ url_for('company_detail', slug=entity.company_id) }}" class="action-btn-link" style="font-size: 10px;">→ Firma Norda</a>
{% endif %}
</td>
<td class="entity-dates">
{% if entity.first_mentioned_at %}
<div>Pierwsza: {{ entity.first_mentioned_at[:10] }}</div>
{% endif %}
{% if entity.last_mentioned_at %}
<div>Ostatnia: {{ entity.last_mentioned_at[:10] }}</div>
{% endif %}
</td>
<td>
<button class="action-btn action-btn-verify" onclick="toggleVerify({{ entity.id }}, {{ 'false' if entity.is_verified else 'true' }})">
{{ '✗' if entity.is_verified else '✓' }}
</button>
{% if entity.external_url %}
<a href="{{ entity.external_url }}" target="_blank" class="action-btn action-btn-link">🔗</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="text-align: center; padding: var(--spacing-xl); color: var(--text-secondary);">
Brak encji do wyświetlenia
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_zopk_knowledge_entities', page=page-1, entity_type=current_entity_type, is_verified=is_verified, min_mentions=min_mentions) }}"> Poprzednia</a>
{% else %}
<span class="disabled"> Poprzednia</span>
{% endif %}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p >= pages - 2 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin_zopk_knowledge_entities', page=p, entity_type=current_entity_type, is_verified=is_verified, min_mentions=min_mentions) }}">{{ p }}</a>
{% elif p == 4 or p == pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < pages %}
<a href="{{ url_for('admin_zopk_knowledge_entities', page=page+1, entity_type=current_entity_type, is_verified=is_verified, min_mentions=min_mentions) }}">Następna </a>
{% else %}
<span class="disabled">Następna </span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
async function toggleVerify(id, newState) {
try {
const response = await fetch(`/api/zopk/knowledge/entities/${id}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ is_verified: newState })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Błąd: ' + data.error);
}
} catch (error) {
alert('Błąd: ' + error.message);
}
}
{% endblock %}

View File

@ -0,0 +1,441 @@
{% extends "base.html" %}
{% block title %}Fakty - Baza Wiedzy ZOPK{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.page-header h1 {
font-size: var(--font-size-2xl);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.breadcrumb a {
color: var(--text-secondary);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary);
font-size: var(--font-size-sm);
transition: var(--transition);
cursor: pointer;
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.table-wrapper {
overflow-x: auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.data-table {
width: 100%;
min-width: 900px;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--background);
}
.fact-text {
max-width: 400px;
font-size: var(--font-size-sm);
line-height: 1.5;
}
.fact-structure {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.fact-structure strong {
color: var(--primary);
}
.fact-source {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.fact-source a {
color: var(--primary);
text-decoration: none;
}
.fact-source a:hover {
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-verified { background: #d1fae5; color: #065f46; }
.status-pending { background: #fef3c7; color: #92400e; }
.fact-type-badge {
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.fact-type-statistic { background: #dbeafe; color: #1e40af; }
.fact-type-event { background: #fce7f3; color: #be185d; }
.fact-type-statement { background: #e0e7ff; color: #3730a3; }
.fact-type-decision { background: #d1fae5; color: #065f46; }
.fact-type-milestone { background: #fef3c7; color: #92400e; }
.fact-type-financial { background: #fef3c7; color: #92400e; }
.fact-type-date { background: #f3e8ff; color: #7c3aed; }
.numeric-value {
font-weight: 600;
color: var(--primary);
}
.action-btn {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
font-size: var(--font-size-xs);
transition: var(--transition);
margin-right: var(--spacing-xs);
}
.action-btn-verify {
background: #d1fae5;
color: #065f46;
}
.action-btn-verify:hover {
background: #065f46;
color: white;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-lg);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
}
.pagination a:hover {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .current {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.pagination .disabled {
opacity: 0.5;
pointer-events: none;
}
.stats-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
}
.entity-tag {
display: inline-block;
padding: 1px 6px;
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
margin: 1px;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin_dashboard') }}">Panel Admina</a>
<span></span>
<a href="{{ url_for('admin_zopk_dashboard') }}">ZOP Kaszubia</a>
<span></span>
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Fakty</span>
</div>
<div class="page-header">
<h1>📌 Fakty (wyekstraktowane informacje)</h1>
</div>
<!-- Filters -->
<div class="filters">
<span style="color: var(--text-secondary); font-size: var(--font-size-sm);">Typ faktu:</span>
<a href="{{ url_for('admin_zopk_knowledge_facts') }}"
class="filter-btn {{ 'active' if current_fact_type is none else '' }}">
Wszystkie
</a>
{% for ftype in fact_types %}
<a href="{{ url_for('admin_zopk_knowledge_facts', fact_type=ftype) }}"
class="filter-btn {{ 'active' if current_fact_type == ftype else '' }}">
{{ ftype }}
</a>
{% endfor %}
<span style="margin-left: 20px; color: var(--text-secondary); font-size: var(--font-size-sm);">Status:</span>
<a href="{{ url_for('admin_zopk_knowledge_facts', is_verified='true', fact_type=current_fact_type) }}"
class="filter-btn {{ 'active' if is_verified == true else '' }}">
✓ Zweryfikowane
</a>
<a href="{{ url_for('admin_zopk_knowledge_facts', is_verified='false', fact_type=current_fact_type) }}"
class="filter-btn {{ 'active' if is_verified == false else '' }}">
⏳ Oczekujące
</a>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<span>Pokazuję {{ facts|length }} z {{ total }} faktów (strona {{ page }} z {{ pages }})</span>
<span>
{% if source_news_id %}
Źródło: Artykuł #{{ source_news_id }}
<a href="{{ url_for('admin_zopk_knowledge_facts') }}" style="margin-left: 8px; color: var(--primary);">✕ usuń filtr</a>
{% endif %}
</span>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Fakt</th>
<th>Typ</th>
<th>Wartość</th>
<th>Encje</th>
<th>Status</th>
<th>Źródło</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for fact in facts %}
<tr id="fact-row-{{ fact.id }}">
<td>#{{ fact.id }}</td>
<td class="fact-text">
{{ fact.full_text }}
{% if fact.subject or fact.predicate or fact.object %}
<div class="fact-structure">
{% if fact.subject %}<strong>Podmiot:</strong> {{ fact.subject }}{% endif %}
{% if fact.predicate %}<strong>Relacja:</strong> {{ fact.predicate }}{% endif %}
{% if fact.object %}<strong>Obiekt:</strong> {{ fact.object[:50] }}{% if fact.object|length > 50 %}...{% endif %}{% endif %}
</div>
{% endif %}
</td>
<td>
{% if fact.fact_type %}
<span class="fact-type-badge fact-type-{{ fact.fact_type }}">{{ fact.fact_type }}</span>
{% else %}
<span style="color: var(--text-tertiary);"></span>
{% endif %}
</td>
<td>
{% if fact.numeric_value %}
<span class="numeric-value">{{ fact.numeric_value|round(2) }}</span>
{% if fact.numeric_unit %}{{ fact.numeric_unit }}{% endif %}
{% elif fact.date_value %}
<span class="numeric-value">{{ fact.date_value }}</span>
{% else %}
<span style="color: var(--text-tertiary);"></span>
{% endif %}
</td>
<td>
{% if fact.entities_involved %}
{% for e in fact.entities_involved[:3] %}
<span class="entity-tag">{{ e.name if e.name else e }}</span>
{% endfor %}
{% if fact.entities_involved|length > 3 %}
<span class="entity-tag">+{{ fact.entities_involved|length - 3 }}</span>
{% endif %}
{% else %}
<span style="color: var(--text-tertiary);"></span>
{% endif %}
</td>
<td>
{% if fact.is_verified %}
<span class="status-badge status-verified">✓ Zweryfikowany</span>
{% else %}
<span class="status-badge status-pending">⏳ Oczekuje</span>
{% endif %}
{% if fact.confidence_score %}
<br><small style="color: var(--text-tertiary);">{{ (fact.confidence_score * 100)|round }}% pewności</small>
{% endif %}
</td>
<td class="fact-source">
{% if fact.source_news_id %}
<a href="{{ url_for('admin_zopk_knowledge_facts', source_news_id=fact.source_news_id) }}">
Art. #{{ fact.source_news_id }}
</a>
{% if fact.source_title %}
<br>{{ fact.source_title[:40] }}...
{% endif %}
{% endif %}
</td>
<td>
<button class="action-btn action-btn-verify" onclick="toggleVerify({{ fact.id }}, {{ 'false' if fact.is_verified else 'true' }})">
{{ '✗' if fact.is_verified else '✓' }}
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" style="text-align: center; padding: var(--spacing-xl); color: var(--text-secondary);">
Brak faktów do wyświetlenia
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_zopk_knowledge_facts', page=page-1, fact_type=current_fact_type, is_verified=is_verified, source_news_id=source_news_id) }}"> Poprzednia</a>
{% else %}
<span class="disabled"> Poprzednia</span>
{% endif %}
{% for p in range(1, pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p >= pages - 2 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin_zopk_knowledge_facts', page=p, fact_type=current_fact_type, is_verified=is_verified, source_news_id=source_news_id) }}">{{ p }}</a>
{% elif p == 4 or p == pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < pages %}
<a href="{{ url_for('admin_zopk_knowledge_facts', page=page+1, fact_type=current_fact_type, is_verified=is_verified, source_news_id=source_news_id) }}">Następna </a>
{% else %}
<span class="disabled">Następna </span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
async function toggleVerify(id, newState) {
try {
const response = await fetch(`/api/zopk/knowledge/facts/${id}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ is_verified: newState })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Błąd: ' + data.error);
}
} catch (error) {
alert('Błąd: ' + error.message);
}
}
{% endblock %}

View File

@ -332,6 +332,195 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Timeline / Roadmapa */
.timeline {
position: relative;
padding: 0 0 0 40px;
margin-bottom: var(--spacing-2xl);
}
.timeline::before {
content: '';
position: absolute;
left: 15px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--primary) 0%, #10b981 50%, #6366f1 100%);
border-radius: 2px;
}
.timeline-item {
position: relative;
padding-bottom: var(--spacing-xl);
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-icon {
position: absolute;
left: -40px;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 1;
box-shadow: 0 0 0 4px var(--background);
}
.timeline-icon.status-completed {
background: #10b981;
color: white;
}
.timeline-icon.status-in_progress {
background: #3b82f6;
color: white;
animation: pulse 2s infinite;
}
.timeline-icon.status-planned {
background: #f59e0b;
color: white;
}
.timeline-icon.status-delayed {
background: #ef4444;
color: white;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--background); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0.2); }
}
.timeline-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
border-left: 4px solid var(--primary);
transition: var(--transition);
}
.timeline-card:hover {
box-shadow: var(--shadow-md);
transform: translateX(4px);
}
.timeline-card.featured {
border-left-width: 6px;
background: linear-gradient(135deg, var(--surface) 0%, #f0fdf4 100%);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-md);
margin-bottom: var(--spacing-sm);
}
.timeline-card h4 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0;
}
.timeline-date {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.timeline-date svg {
width: 14px;
height: 14px;
}
.timeline-card p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.timeline-meta {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-sm);
flex-wrap: wrap;
}
.timeline-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.badge-completed { background: #dcfce7; color: #166534; }
.badge-in_progress { background: #dbeafe; color: #1e40af; }
.badge-planned { background: #fef3c7; color: #92400e; }
.badge-delayed { background: #fee2e2; color: #991b1b; }
.timeline-type {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.timeline-source {
font-size: var(--font-size-xs);
color: var(--primary);
text-decoration: none;
}
.timeline-source:hover {
text-decoration: underline;
}
.timeline-year-marker {
position: relative;
padding: var(--spacing-sm) 0 var(--spacing-lg) 0;
}
.timeline-year-marker::before {
content: '';
position: absolute;
left: -40px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 4px var(--background);
}
.timeline-year {
display: inline-block;
background: var(--primary);
color: white;
padding: 4px 12px;
border-radius: var(--radius);
font-weight: 700;
font-size: var(--font-size-sm);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.zopk-hero { .zopk-hero {
padding: var(--spacing-xl) var(--spacing-md); padding: var(--spacing-xl) var(--spacing-md);
@ -354,6 +543,30 @@
width: 100%; width: 100%;
height: 150px; height: 150px;
} }
.timeline {
padding-left: 30px;
}
.timeline::before {
left: 10px;
}
.timeline-icon {
left: -30px;
width: 24px;
height: 24px;
font-size: 11px;
}
.timeline-header {
flex-direction: column;
gap: var(--spacing-xs);
}
.timeline-card h4 {
font-size: var(--font-size-base);
}
} }
</style> </style>
{% endblock %} {% endblock %}
@ -425,6 +638,85 @@
{% endif %} {% endif %}
</section> </section>
<!-- Timeline / Roadmapa Section -->
{% if milestones %}
<section>
<div class="section-header">
<h2>🗺️ Roadmapa ZOPK</h2>
</div>
<div class="timeline">
{% set ns = namespace(current_year=None) %}
{% for milestone in milestones %}
{# Year marker when year changes #}
{% if milestone.target_date %}
{% set milestone_year = milestone.target_date.year %}
{% if milestone_year != ns.current_year %}
{% set ns.current_year = milestone_year %}
<div class="timeline-year-marker">
<span class="timeline-year">{{ milestone_year }}</span>
</div>
{% endif %}
{% endif %}
<div class="timeline-item">
<div class="timeline-icon status-{{ milestone.status }}" title="{{ milestone.status }}">
{{ milestone.icon or '📌' }}
</div>
<div class="timeline-card{% if milestone.is_featured %} featured{% endif %}" style="border-left-color: {{ milestone.color or '#059669' }}">
<div class="timeline-header">
<h4>{{ milestone.title }}</h4>
{% if milestone.target_date %}
<span class="timeline-date">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
{% if milestone.date_precision == 'month' %}
{{ milestone.target_date.strftime('%m/%Y') }}
{% elif milestone.date_precision == 'quarter' %}
Q{{ ((milestone.target_date.month - 1) // 3) + 1 }} {{ milestone.target_date.year }}
{% elif milestone.date_precision == 'year' %}
{{ milestone.target_date.year }}
{% else %}
{{ milestone.target_date.strftime('%d.%m.%Y') }}
{% endif %}
</span>
{% endif %}
</div>
{% if milestone.description %}
<p>{{ milestone.description[:200] }}{% if milestone.description|length > 200 %}...{% endif %}</p>
{% endif %}
<div class="timeline-meta">
<span class="timeline-badge badge-{{ milestone.status }}">
{% if milestone.status == 'completed' %}✅ Zrealizowano
{% elif milestone.status == 'in_progress' %}🔄 W trakcie
{% elif milestone.status == 'planned' %}⏳ Planowane
{% elif milestone.status == 'delayed' %}⚠️ Opóźnione
{% elif milestone.status == 'cancelled' %}❌ Anulowane
{% else %}{{ milestone.status }}{% endif %}
</span>
<span class="timeline-type">
{% if milestone.milestone_type == 'announcement' %}📢 Ogłoszenie
{% elif milestone.milestone_type == 'decision' %}⚖️ Decyzja
{% elif milestone.milestone_type == 'construction_start' %}🏗️ Start budowy
{% elif milestone.milestone_type == 'construction_progress' %}🔨 Postęp budowy
{% elif milestone.milestone_type == 'completion' %}🎉 Ukończenie
{% elif milestone.milestone_type == 'investment' %}💰 Inwestycja
{% elif milestone.milestone_type == 'agreement' %}📝 Porozumienie
{% elif milestone.milestone_type == 'regulation' %}📋 Regulacja
{% else %}{{ milestone.milestone_type }}{% endif %}
</span>
{% if milestone.source_url %}
<a href="{{ milestone.source_url }}" target="_blank" rel="noopener" class="timeline-source">
🔗 Źródło
</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Stakeholders Section --> <!-- Stakeholders Section -->
<section> <section>
<div class="section-header"> <div class="section-header">

View File

@ -1031,6 +1031,16 @@ def get_relevant_facts(
score += 1 score += 1
if score > 0: if score > 0:
# Get source URL from related news
source_url = ''
source_name = ''
source_date = ''
if fact.source_news:
source_url = fact.source_news.url or ''
source_name = fact.source_news.source_name or fact.source_news.source_domain or ''
if fact.source_news.published_at:
source_date = fact.source_news.published_at.strftime('%Y-%m-%d')
results.append({ results.append({
'fact_id': fact.id, 'fact_id': fact.id,
'fact_type': fact.fact_type, 'fact_type': fact.fact_type,
@ -1039,7 +1049,10 @@ def get_relevant_facts(
'numeric_value': float(fact.numeric_value) if fact.numeric_value else None, 'numeric_value': float(fact.numeric_value) if fact.numeric_value else None,
'numeric_unit': fact.numeric_unit, 'numeric_unit': fact.numeric_unit,
'confidence': float(fact.confidence_score) if fact.confidence_score else None, 'confidence': float(fact.confidence_score) if fact.confidence_score else None,
'relevance_score': score 'relevance_score': score,
'source_url': source_url,
'source_name': source_name,
'source_date': source_date
}) })
# Sort by relevance # Sort by relevance
@ -1228,3 +1241,415 @@ def get_knowledge_stats(db_session) -> Dict:
""" """
service = ZOPKKnowledgeService(db_session) service = ZOPKKnowledgeService(db_session)
return service.get_extraction_statistics() return service.get_extraction_statistics()
# ============================================================
# ADMIN PANEL - LIST FUNCTIONS
# ============================================================
def list_chunks(
db_session,
page: int = 1,
per_page: int = 20,
source_news_id: Optional[int] = None,
has_embedding: Optional[bool] = None,
is_verified: Optional[bool] = None
) -> Dict:
"""
List knowledge chunks with pagination and filtering.
Args:
db_session: Database session
page: Page number (1-based)
per_page: Items per page
source_news_id: Filter by source article
has_embedding: Filter by embedding status
is_verified: Filter by verification status
Returns:
{
'chunks': [...],
'total': int,
'page': int,
'per_page': int,
'pages': int
}
"""
from sqlalchemy import func
query = db_session.query(ZOPKKnowledgeChunk)
# Apply filters
if source_news_id:
query = query.filter(ZOPKKnowledgeChunk.source_news_id == source_news_id)
if has_embedding is not None:
if has_embedding:
query = query.filter(ZOPKKnowledgeChunk.embedding.isnot(None))
else:
query = query.filter(ZOPKKnowledgeChunk.embedding.is_(None))
if is_verified is not None:
query = query.filter(ZOPKKnowledgeChunk.is_verified == is_verified)
# Get total count
total = query.count()
# Calculate pagination
pages = (total + per_page - 1) // per_page
offset = (page - 1) * per_page
# Get chunks with source news info
chunks = query.order_by(
ZOPKKnowledgeChunk.created_at.desc()
).offset(offset).limit(per_page).all()
return {
'chunks': [
{
'id': c.id,
'content': c.content[:300] + '...' if len(c.content) > 300 else c.content,
'full_content': c.content,
'summary': c.summary,
'chunk_type': c.chunk_type,
'chunk_index': c.chunk_index,
'token_count': c.token_count,
'importance_score': c.importance_score,
'confidence_score': float(c.confidence_score) if c.confidence_score else None,
'has_embedding': c.embedding is not None,
'is_verified': c.is_verified,
'source_news_id': c.source_news_id,
'source_title': c.source_news.title if c.source_news else None,
'source_url': c.source_news.url if c.source_news else None,
'created_at': c.created_at.isoformat() if c.created_at else None,
'keywords': c.keywords if isinstance(c.keywords, list) else []
}
for c in chunks
],
'total': total,
'page': page,
'per_page': per_page,
'pages': pages
}
def list_facts(
db_session,
page: int = 1,
per_page: int = 20,
fact_type: Optional[str] = None,
is_verified: Optional[bool] = None,
source_news_id: Optional[int] = None
) -> Dict:
"""
List knowledge facts with pagination and filtering.
Args:
db_session: Database session
page: Page number (1-based)
per_page: Items per page
fact_type: Filter by fact type (statistic, event, statement, decision, milestone)
is_verified: Filter by verification status
source_news_id: Filter by source article
Returns:
{
'facts': [...],
'total': int,
'page': int,
'per_page': int,
'pages': int,
'fact_types': [...] - available types for filtering
}
"""
from sqlalchemy import func, distinct
query = db_session.query(ZOPKKnowledgeFact)
# Apply filters
if fact_type:
query = query.filter(ZOPKKnowledgeFact.fact_type == fact_type)
if is_verified is not None:
query = query.filter(ZOPKKnowledgeFact.is_verified == is_verified)
if source_news_id:
query = query.filter(ZOPKKnowledgeFact.source_news_id == source_news_id)
# Get total count
total = query.count()
# Get available fact types
fact_types = db_session.query(
distinct(ZOPKKnowledgeFact.fact_type)
).filter(
ZOPKKnowledgeFact.fact_type.isnot(None)
).all()
fact_types = [f[0] for f in fact_types if f[0]]
# Calculate pagination
pages = (total + per_page - 1) // per_page
offset = (page - 1) * per_page
# Get facts
facts = query.order_by(
ZOPKKnowledgeFact.created_at.desc()
).offset(offset).limit(per_page).all()
return {
'facts': [
{
'id': f.id,
'fact_type': f.fact_type,
'subject': f.subject,
'predicate': f.predicate,
'object': f.object,
'full_text': f.full_text,
'numeric_value': float(f.numeric_value) if f.numeric_value else None,
'numeric_unit': f.numeric_unit,
'date_value': f.date_value.isoformat() if f.date_value else None,
'confidence_score': float(f.confidence_score) if f.confidence_score else None,
'is_verified': f.is_verified,
'source_news_id': f.source_news_id,
'source_chunk_id': f.source_chunk_id,
'source_title': f.source_news.title if f.source_news else None,
'source_url': f.source_news.url if f.source_news else None,
'entities_involved': f.entities_involved if isinstance(f.entities_involved, list) else [],
'created_at': f.created_at.isoformat() if f.created_at else None
}
for f in facts
],
'total': total,
'page': page,
'per_page': per_page,
'pages': pages,
'fact_types': fact_types
}
def list_entities(
db_session,
page: int = 1,
per_page: int = 20,
entity_type: Optional[str] = None,
is_verified: Optional[bool] = None,
min_mentions: Optional[int] = None
) -> Dict:
"""
List knowledge entities with pagination and filtering.
Args:
db_session: Database session
page: Page number (1-based)
per_page: Items per page
entity_type: Filter by entity type (company, person, place, organization, project, technology)
is_verified: Filter by verification status
min_mentions: Filter by minimum mention count
Returns:
{
'entities': [...],
'total': int,
'page': int,
'per_page': int,
'pages': int,
'entity_types': [...] - available types for filtering
}
"""
from sqlalchemy import func, distinct
query = db_session.query(ZOPKKnowledgeEntity)
# Exclude merged entities
query = query.filter(ZOPKKnowledgeEntity.merged_into_id.is_(None))
# Apply filters
if entity_type:
query = query.filter(ZOPKKnowledgeEntity.entity_type == entity_type)
if is_verified is not None:
query = query.filter(ZOPKKnowledgeEntity.is_verified == is_verified)
if min_mentions:
query = query.filter(ZOPKKnowledgeEntity.mentions_count >= min_mentions)
# Get total count
total = query.count()
# Get available entity types
entity_types = db_session.query(
distinct(ZOPKKnowledgeEntity.entity_type)
).filter(
ZOPKKnowledgeEntity.entity_type.isnot(None)
).all()
entity_types = [e[0] for e in entity_types if e[0]]
# Calculate pagination
pages = (total + per_page - 1) // per_page
offset = (page - 1) * per_page
# Get entities sorted by mentions
entities = query.order_by(
ZOPKKnowledgeEntity.mentions_count.desc()
).offset(offset).limit(per_page).all()
return {
'entities': [
{
'id': e.id,
'name': e.name,
'normalized_name': e.normalized_name,
'entity_type': e.entity_type,
'description': e.description,
'short_description': e.short_description,
'aliases': e.aliases if isinstance(e.aliases, list) else [],
'mentions_count': e.mentions_count or 0,
'is_verified': e.is_verified,
'company_id': e.company_id,
'external_url': e.external_url,
'first_mentioned_at': e.first_mentioned_at.isoformat() if e.first_mentioned_at else None,
'last_mentioned_at': e.last_mentioned_at.isoformat() if e.last_mentioned_at else None,
'created_at': e.created_at.isoformat() if e.created_at else None
}
for e in entities
],
'total': total,
'page': page,
'per_page': per_page,
'pages': pages,
'entity_types': entity_types
}
def get_chunk_detail(db_session, chunk_id: int) -> Optional[Dict]:
"""Get detailed information about a single chunk."""
chunk = db_session.query(ZOPKKnowledgeChunk).filter(
ZOPKKnowledgeChunk.id == chunk_id
).first()
if not chunk:
return None
# Get facts from this chunk
facts = db_session.query(ZOPKKnowledgeFact).filter(
ZOPKKnowledgeFact.source_chunk_id == chunk_id
).all()
# Get entity mentions
mentions = db_session.query(ZOPKKnowledgeEntityMention).filter(
ZOPKKnowledgeEntityMention.chunk_id == chunk_id
).all()
return {
'id': chunk.id,
'content': chunk.content,
'content_clean': chunk.content_clean,
'summary': chunk.summary,
'chunk_type': chunk.chunk_type,
'chunk_index': chunk.chunk_index,
'token_count': chunk.token_count,
'importance_score': chunk.importance_score,
'confidence_score': float(chunk.confidence_score) if chunk.confidence_score else None,
'has_embedding': chunk.embedding is not None,
'is_verified': chunk.is_verified,
'keywords': chunk.keywords if isinstance(chunk.keywords, list) else [],
'context_date': chunk.context_date.isoformat() if chunk.context_date else None,
'context_location': chunk.context_location,
'extraction_model': chunk.extraction_model,
'extracted_at': chunk.extracted_at.isoformat() if chunk.extracted_at else None,
'created_at': chunk.created_at.isoformat() if chunk.created_at else None,
'source_news': {
'id': chunk.source_news.id,
'title': chunk.source_news.title,
'url': chunk.source_news.url,
'source_name': chunk.source_news.source_name
} if chunk.source_news else None,
'facts': [
{
'id': f.id,
'fact_type': f.fact_type,
'full_text': f.full_text,
'is_verified': f.is_verified
}
for f in facts
],
'entity_mentions': [
{
'id': m.id,
'entity_id': m.entity_id,
'entity_name': m.entity.name if m.entity else None,
'entity_type': m.entity.entity_type if m.entity else None,
'mention_text': m.mention_text
}
for m in mentions
]
}
def update_chunk_verification(db_session, chunk_id: int, is_verified: bool, user_id: int) -> bool:
"""Update chunk verification status."""
chunk = db_session.query(ZOPKKnowledgeChunk).filter(
ZOPKKnowledgeChunk.id == chunk_id
).first()
if not chunk:
return False
chunk.is_verified = is_verified
chunk.verified_by = user_id
chunk.verified_at = datetime.now()
db_session.commit()
return True
def update_fact_verification(db_session, fact_id: int, is_verified: bool) -> bool:
"""Update fact verification status."""
fact = db_session.query(ZOPKKnowledgeFact).filter(
ZOPKKnowledgeFact.id == fact_id
).first()
if not fact:
return False
fact.is_verified = is_verified
db_session.commit()
return True
def update_entity_verification(db_session, entity_id: int, is_verified: bool) -> bool:
"""Update entity verification status."""
entity = db_session.query(ZOPKKnowledgeEntity).filter(
ZOPKKnowledgeEntity.id == entity_id
).first()
if not entity:
return False
entity.is_verified = is_verified
db_session.commit()
return True
def delete_chunk(db_session, chunk_id: int) -> bool:
"""Delete a chunk and its associated facts and mentions."""
chunk = db_session.query(ZOPKKnowledgeChunk).filter(
ZOPKKnowledgeChunk.id == chunk_id
).first()
if not chunk:
return False
# Delete associated facts
db_session.query(ZOPKKnowledgeFact).filter(
ZOPKKnowledgeFact.source_chunk_id == chunk_id
).delete()
# Delete associated mentions
db_session.query(ZOPKKnowledgeEntityMention).filter(
ZOPKKnowledgeEntityMention.chunk_id == chunk_id
).delete()
# Delete chunk
db_session.delete(chunk)
db_session.commit()
return True