From 46793f4acfb010dd61d3655f3ae5fcf3cc93df0a Mon Sep 17 00:00:00 2001 From: Ruben Fischer Date: Mon, 16 Mar 2026 11:21:43 +0100 Subject: [PATCH] added strtegy import and amployee strategy --- .env.example | 6 + src/agents/__init__.py | 2 + src/agents/strategy_importer.py | 69 ++ src/config.py | 11 + src/services/insights_summary_service.py | 82 ++ src/web/templates/user/base.html | 11 +- src/web/templates/user/company_strategy.html | 195 +++- src/web/templates/user/employee_base.html | 2 +- .../templates/user/employee_dashboard.html | 2 +- src/web/templates/user/employee_insights.html | 237 ----- src/web/templates/user/employee_strategy.html | 832 +++++++++++++++--- src/web/user/routes.py | 166 +++- 12 files changed, 1200 insertions(+), 415 deletions(-) create mode 100644 src/agents/strategy_importer.py create mode 100644 src/services/insights_summary_service.py delete mode 100644 src/web/templates/user/employee_insights.html diff --git a/.env.example b/.env.example index 2bc737c..e85869d 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ PERPLEXITY_API_KEY=pplx-your-perplexity-key # Apify API Key (required for LinkedIn scraping) APIFY_API_KEY=apify_api_your-apify-key +# NVIDIA API Key (Insights summary) +NVIDIA_API_KEY=nvapi-your-nvidia-key + # =========================================== # Supabase Database # =========================================== @@ -37,6 +40,9 @@ APIFY_ACTOR_ID=apimaestro~linkedin-profile-posts DEBUG=false LOG_LEVEL=INFO +# Insights +INSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED=true + # =========================================== # Email Settings (for sending posts) # =========================================== diff --git a/src/agents/__init__.py b/src/agents/__init__.py index a1b94ee..a06a790 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -7,6 +7,7 @@ from src.agents.writer import WriterAgent from src.agents.critic import CriticAgent from src.agents.post_classifier import PostClassifierAgent from src.agents.post_type_analyzer import PostTypeAnalyzerAgent +from src.agents.strategy_importer import StrategyImporterAgent __all__ = [ "BaseAgent", @@ -17,4 +18,5 @@ __all__ = [ "CriticAgent", "PostClassifierAgent", "PostTypeAnalyzerAgent", + "StrategyImporterAgent", ] diff --git a/src/agents/strategy_importer.py b/src/agents/strategy_importer.py new file mode 100644 index 0000000..75bd915 --- /dev/null +++ b/src/agents/strategy_importer.py @@ -0,0 +1,69 @@ +"""Company strategy importer agent.""" +import json +from typing import Any, Dict, Optional + +from src.agents.base import BaseAgent + + +class StrategyImporterAgent(BaseAgent): + """Convert a strategy document into structured company strategy fields.""" + + def __init__(self) -> None: + super().__init__("StrategyImporter") + + async def process(self, document_text: str, company_name: Optional[str] = None) -> Dict[str, Any]: + system_prompt = ( + "Du bist ein erfahrener Strategy Analyst und strukturierst Unternehmensstrategien " + "für LinkedIn-Content. Du erhältst den Text eines Strategie-Dokuments und musst daraus " + "ein klares, ausführliches JSON im vorgegebenen Format erstellen." + ) + + user_prompt = f""" +Unternehmensname: {company_name or "Unbekannt"} + +Analysiere den folgenden Dokumenttext und extrahiere eine vollständige Unternehmensstrategie. +Die Antwort MUSS valides JSON sein und exakt dieses Schema enthalten: + +{{ + "mission": "String", + "vision": "String", + "brand_voice": "String", + "tone_guidelines": "String", + "target_audience": "String", + "content_pillars": ["String", "..."], + "dos": ["String", "..."], + "donts": ["String", "..."] +}} + +Anforderungen: +- Schreibe auf Deutsch. +- Fülle alle Felder so vollständig wie möglich. +- Nutze ausschließlich Informationen aus dem Dokument. Wenn etwas implizit ist, formuliere es vorsichtig. +- Content-Säulen, Do's und Don'ts sollen konkrete, umsetzbare Punkte sein. + +Dokumenttext: +{document_text} +""" + + response = await self.call_openai( + system_prompt=system_prompt, + user_prompt=user_prompt, + model="gpt-4o", + temperature=0.2, + response_format={"type": "json_object"} + ) + + parsed = json.loads(response) + if not isinstance(parsed, dict): + raise ValueError("Ungültiges JSON vom Modell.") + + return { + "mission": parsed.get("mission", ""), + "vision": parsed.get("vision", ""), + "brand_voice": parsed.get("brand_voice", ""), + "tone_guidelines": parsed.get("tone_guidelines", ""), + "target_audience": parsed.get("target_audience", ""), + "content_pillars": parsed.get("content_pillars", []) or [], + "dos": parsed.get("dos", []) or [], + "donts": parsed.get("donts", []) or [], + } diff --git a/src/config.py b/src/config.py index a3eac96..cff727f 100644 --- a/src/config.py +++ b/src/config.py @@ -1,6 +1,7 @@ """Configuration management for LinkedIn Workflow.""" from typing import Optional from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, AliasChoices from pathlib import Path @@ -61,6 +62,9 @@ class Settings(BaseSettings): # Token Encryption encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + # NVIDIA (free model for insights summary) + nvidia_api_key: str = "" + # MOCO Integration moco_api_key: str = "" # Token für Authorization-Header moco_domain: str = "" # Subdomain: {domain}.mocoapp.com @@ -68,6 +72,13 @@ class Settings(BaseSettings): # Redis redis_url: str = "redis://redis:6379/0" scheduler_enabled: bool = False # True only on dedicated scheduler container + insights_manual_refresh_limit_enabled: bool = Field( + default=True, + validation_alias=AliasChoices( + "INSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED", + "NSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED" + ), + ) # YouTube (optional) youtube_cookies: str = "" # Raw Cookie header value for transcript fetching diff --git a/src/services/insights_summary_service.py b/src/services/insights_summary_service.py new file mode 100644 index 0000000..fd7761e --- /dev/null +++ b/src/services/insights_summary_service.py @@ -0,0 +1,82 @@ +"""Generate a short insights summary using NVIDIA-hosted LLM.""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from loguru import logger +from openai import OpenAI + +from src.config import settings + + +def _build_prompt(post_insights: Dict[str, Any]) -> str: + return ( + "Erstelle eine kurze, prägnante Zusammenfassung (2-4 Sätze) der wichtigsten " + "Insights aus den folgenden LinkedIn-Post-Metriken. Fokus: Trends, auffällige " + "Performance, was gut funktioniert.\n\n" + f"Posts: {post_insights.get('total_posts')}\n" + f"Ø Reaktionen/Post: {post_insights.get('avg_reactions')}\n" + f"Ø Likes: {post_insights.get('avg_likes')}\n" + f"Ø Comments: {post_insights.get('avg_comments')}\n" + f"Ø Shares: {post_insights.get('avg_shares')}\n" + f"Letzte 7 Tage Reaktionen: {post_insights.get('last_7_reactions')}\n" + f"Trend vs Vorwoche (%): {post_insights.get('trend_pct')}\n" + f"Bester Wochentag: {post_insights.get('best_weekday')}\n" + f"Posting-Kadenz/Woche: {post_insights.get('cadence_per_week')}\n" + ) + + +def _is_summary_complete(text: str) -> bool: + if not text: + return False + cleaned = text.strip() + if len(cleaned) < 60: + return False + if cleaned[-1] not in ".!?": + return False + return True + + +def generate_insights_summary(post_insights: Dict[str, Any]) -> Optional[str]: + """Generate a short summary for the insights page.""" + if not settings.nvidia_api_key: + return None + + try: + client = OpenAI( + base_url="https://integrate.api.nvidia.com/v1", + api_key=settings.nvidia_api_key, + ) + + completion = client.chat.completions.create( + model="deepseek-ai/deepseek-v3.2", + messages=[{"role": "user", "content": _build_prompt(post_insights)}], + temperature=0.6, + top_p=0.95, + max_tokens=300, + ) + + content = None + if completion and completion.choices: + content = completion.choices[0].message.content + if content: + text = content.strip() + if _is_summary_complete(text): + return text + + # Retry once with stricter settings if output looks incomplete + retry = client.chat.completions.create( + model="deepseek-ai/deepseek-v3.2", + messages=[{"role": "user", "content": _build_prompt(post_insights)}], + temperature=0.4, + top_p=0.9, + max_tokens=220, + ) + if retry and retry.choices: + text = (retry.choices[0].message.content or "").strip() + if _is_summary_complete(text): + return text + return None + except Exception as e: + logger.warning(f"Failed to generate insights summary: {e}") + return None diff --git a/src/web/templates/user/base.html b/src/web/templates/user/base.html index 7b0e4a7..e3d242c 100644 --- a/src/web/templates/user/base.html +++ b/src/web/templates/user/base.html @@ -157,16 +157,7 @@ - Unternehmensstrategie - - {% endif %} - {% if session and session.account_type == 'employee' %} - - - - - Post Insights - Neu + Strategie {% endif %} diff --git a/src/web/templates/user/company_strategy.html b/src/web/templates/user/company_strategy.html index 8416faa..bd60391 100644 --- a/src/web/templates/user/company_strategy.html +++ b/src/web/templates/user/company_strategy.html @@ -4,9 +4,55 @@ {% block content %}
-

Unternehmensstrategie

-

Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.

+ + +
+

Unternehmensstrategie

+
+ + +
+
+

Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.

+

PDF hochladen, analysieren lassen und die Felder automatisch befüllen.

+ +
{% if success %}
Strategie erfolgreich gespeichert! @@ -200,6 +246,7 @@
+
{% endblock %} diff --git a/src/web/templates/user/employee_base.html b/src/web/templates/user/employee_base.html index c4639a8..3db0b38 100644 --- a/src/web/templates/user/employee_base.html +++ b/src/web/templates/user/employee_base.html @@ -88,7 +88,7 @@ - Unternehmensstrategie + Strategie diff --git a/src/web/templates/user/employee_dashboard.html b/src/web/templates/user/employee_dashboard.html index 31aacec..8006c57 100644 --- a/src/web/templates/user/employee_dashboard.html +++ b/src/web/templates/user/employee_dashboard.html @@ -106,7 +106,7 @@

Strategie

-

Richtlinien

+

Profil-Analyse

diff --git a/src/web/templates/user/employee_insights.html b/src/web/templates/user/employee_insights.html deleted file mode 100644 index 1ee2752..0000000 --- a/src/web/templates/user/employee_insights.html +++ /dev/null @@ -1,237 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Post Insights{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-
-
-

Post Insights

-

Tägliche Auswertung deiner LinkedIn-Posts

-
- -
- - {% if not linkedin_account %} -
-

Verbinde deinen LinkedIn Account, damit wir täglich Post-Insights aktualisieren können.

- Zum LinkedIn Login -
- {% elif not post_insights or not post_insights.has_data %} -
-

Noch keine Insights vorhanden. Der tägliche Import läuft in den nächsten 24 Stunden.

-
- {% else %} -
-
-

Posts getrackt

-

{{ post_insights.total_posts }}

-
-
-

Ø Reaktionen/Post

-

{{ post_insights.avg_reactions }}

-

Likes {{ post_insights.avg_likes }} · Comments {{ post_insights.avg_comments }} · Shares {{ post_insights.avg_shares }}

-
-
-

Letzte 7 Tage

-

{{ post_insights.last_7_reactions }}

- {% if post_insights.trend_pct is not none %} -

- {% if post_insights.trend_pct >= 0 %}+{% endif %}{{ post_insights.trend_pct }}% vs. Vorwoche -

- {% endif %} -
-
- -
-
-

Engagement-Entwicklung (7 Tage)

-
-
-
-

Reaktions-Mix

-
-
-
- -
-
-

Wochentag-Performance

-
-
-
-

Performance-Driver

-
-
- Bester Wochentag - {{ post_insights.best_weekday or 'N/A' }} -
-
- Posting-Kadenz - {{ post_insights.cadence_per_week or 'N/A' }} Posts/Woche -
-
- Letzter Snapshot - {{ post_insights.latest_snapshot_date.strftime('%d.%m.%Y') if post_insights.latest_snapshot_date else 'N/A' }} -
-
-
-
- -
-

Top Posts (Engagement)

-
- {% for post in post_insights.top_posts %} -
-

{{ post.text[:180] }}{% if post.text|length > 180 %}...{% endif %}

-
- {{ post.post_date.strftime('%d.%m.%Y') if post.post_date else 'N/A' }} - Likes {{ post.likes }} - Comments {{ post.comments }} - Shares {{ post.shares }} - Score {{ post.engagement_score }} - {% if post.post_url %} - Öffnen - {% endif %} -
-
- {% endfor %} -
-
- -
-

Post-Länge vs. Engagement

-
-
- {% endif %} -
-{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/src/web/templates/user/employee_strategy.html b/src/web/templates/user/employee_strategy.html index 4657cfe..6e2a879 100644 --- a/src/web/templates/user/employee_strategy.html +++ b/src/web/templates/user/employee_strategy.html @@ -1,139 +1,723 @@ {% extends "base.html" %} -{% block title %}Unternehmensstrategie - {{ session.company_name }}{% endblock %} +{% block title %}Strategie{% endblock %} {% block content %} -
-
-

Unternehmensstrategie

-

Diese Strategie wird bei der Erstellung deiner LinkedIn-Posts berücksichtigt.

- {% if session.company_name %} -

Richtlinien von {{ session.company_name }}

- {% endif %} +
+
+
+
+
+
+ + + +
+
+

Strategie

+

Strukturierte Profil-Analyse basierend auf deinen LinkedIn-Posts.

+
+
+ {% if analysis_created_at %} +
+ + Letzte Analyse: {{ analysis_created_at.strftime('%d.%m.%Y %H:%M') }} +
+ {% endif %} +
- {% if not strategy or not strategy.mission %} -
+ {% if success %} +
+ Profil-Analyse erfolgreich gespeichert! +
+ {% endif %} + + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if not profile_analysis %} +
-

Dein Unternehmen hat noch keine Strategie definiert.

-

Wende dich an deinen Administrator, um die Unternehmensstrategie einzurichten.

-
- {% else %} -
- -
-

Mission & Vision

-
- {% if strategy.mission %} -
-

Mission

-

{{ strategy.mission }}

-
- {% endif %} - {% if strategy.vision %} -
-

Vision

-

{{ strategy.vision }}

-
- {% endif %} -
-
- - - {% if strategy.brand_voice or strategy.tone_guidelines %} -
-

Brand Voice

-
- {% if strategy.brand_voice %} -
-

Markenstimme

-

{{ strategy.brand_voice }}

-
- {% endif %} - {% if strategy.tone_guidelines %} -
-

Tonalität-Richtlinien

-

{{ strategy.tone_guidelines }}

-
- {% endif %} -
-
- {% endif %} - - - {% if strategy.target_audience %} -
-

Zielgruppe

-

{{ strategy.target_audience }}

-
- {% endif %} - - - {% if strategy.content_pillars and strategy.content_pillars|length > 0 %} -
-

Content-Säulen

-

Die Hauptthemen, über die das Unternehmen postet.

-
- {% for pillar in strategy.content_pillars %} - {{ pillar }} - {% endfor %} -
-
- {% endif %} - - - {% if (strategy.dos and strategy.dos|length > 0) or (strategy.donts and strategy.donts|length > 0) %} -
- - {% if strategy.dos and strategy.dos|length > 0 %} -
-

- - - - Do's -

-
    - {% for do in strategy.dos %} -
  • - - - - {{ do }} -
  • - {% endfor %} -
-
- {% endif %} - - - {% if strategy.donts and strategy.donts|length > 0 %} -
-

- - - - Don'ts -

-
    - {% for dont in strategy.donts %} -
  • - - - - {{ dont }} -
  • - {% endfor %} -
-
- {% endif %} -
- {% endif %} +

Noch keine Profil-Analyse vorhanden.

+

Bitte wende dich an den Administrator, um eine Analyse zu starten.

{% endif %} + +
+ + + +
+
+
+ + + +
+

Schreibstil

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + +
+

Sprachlicher Fingerabdruck

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ {% for phrase in profile_analysis.linguistic_fingerprint.signature_phrases or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.linguistic_fingerprint or not profile_analysis.linguistic_fingerprint.signature_phrases %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.linguistic_fingerprint.narrative_anchors or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.linguistic_fingerprint or not profile_analysis.linguistic_fingerprint.narrative_anchors %} +
+ + +
+ {% endif %} +
+ +
+
+ +
+
+
+ + + +
+

Phrasen-Bibliothek

+
+
+
+ +
+ {% for phrase in profile_analysis.phrase_library.hook_phrases or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.hook_phrases %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.phrase_library.transition_phrases or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.transition_phrases %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.phrase_library.emotional_expressions or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.emotional_expressions %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.phrase_library.cta_phrases or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.cta_phrases %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.phrase_library.filler_expressions or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.filler_expressions %} +
+ + +
+ {% endif %} +
+ +
+ +
+ +
+ {% for phrase in profile_analysis.phrase_library.ending_phrases or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.ending_phrases %} +
+ + +
+ {% endif %} +
+ +
+
+
+ +
+
+
+ + + +
+

Ton-Analyse

+
+
+
+ + +
+
+ +
+ {% for phrase in profile_analysis.tone_analysis.secondary_tones or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.tone_analysis or not profile_analysis.tone_analysis.secondary_tones %} +
+ + +
+ {% endif %} +
+ +
+
+
+ +
+
+
+ + + +
+

Zielgruppen-Insights

+
+
+
+ + +
+
+ + +
+
+
+ +
+ {% for phrase in profile_analysis.audience_insights.pain_points_addressed or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.audience_insights or not profile_analysis.audience_insights.pain_points_addressed %} +
+ + +
+ {% endif %} +
+ +
+
+ +
+
+
+ + + +
+

Topic Patterns

+
+
+
+ +
+ {% for phrase in profile_analysis.topic_patterns.main_topics or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.main_topics %} +
+ + +
+ {% endif %} +
+ +
+
+ +
+ {% for phrase in profile_analysis.topic_patterns.recurring_themes or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.recurring_themes %} +
+ + +
+ {% endif %} +
+ +
+
+ +
+ {% for phrase in profile_analysis.topic_patterns.content_formats or [] %} +
+ + +
+ {% endfor %} + {% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.content_formats %} +
+ + +
+ {% endif %} +
+ +
+
+
+ +
+ + + + + + + + Zusatzdaten + + Optional + +
+
+ Erweiterte Felder als strukturierte Listen. Werte lassen sich hinzufügen oder entfernen. +
+
+ +
+
+ +
+ +
+
+ + {% endblock %} diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 1f86687..558a636 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -44,7 +44,9 @@ from src.services.storage_service import storage from src.services.link_extractor import LinkExtractor, LinkExtractionError from src.services.file_extractor import FileExtractor, FileExtractionError from src.agents.link_topic_builder import LinkTopicBuilderAgent +from src.agents.strategy_importer import StrategyImporterAgent from src.services.post_insights_service import compute_post_insights, refresh_post_insights_for_account +from src.services.insights_summary_service import generate_insights_summary # Router for user frontend user_router = APIRouter(tags=["user"]) @@ -3036,14 +3038,34 @@ async def refresh_post_insights(request: Request): metadata = profile.metadata or {} today = datetime.now(timezone.utc).date().isoformat() last_refresh = metadata.get("post_insights_manual_refresh_date") - if last_refresh == today: + if settings.insights_manual_refresh_limit_enabled and last_refresh == today: raise HTTPException(status_code=429, detail="Manual refresh already used today") - await refresh_post_insights_for_account(db, linkedin_account) + # Mark refresh immediately to avoid concurrent spam metadata["post_insights_manual_refresh_date"] = today await db.update_profile(user_id, {"metadata": metadata}) - return {"success": True, "refreshed_at": today} + async def _bg_refresh_and_summary(): + try: + await refresh_post_insights_for_account(db, linkedin_account) + + from datetime import date, timedelta + since = (date.today() - timedelta(days=90)).isoformat() + insights_posts = await db.get_post_insights_posts(user_id) + insights_daily = await db.get_post_insights_daily(user_id, since_date=since) + post_insights = compute_post_insights(insights_posts, insights_daily) + if post_insights and post_insights.get("has_data"): + summary = await asyncio.to_thread(generate_insights_summary, post_insights) + if summary: + metadata["post_insights_summary"] = summary + metadata["post_insights_summary_date"] = today + await db.update_profile(user_id, {"metadata": metadata}) + except Exception as e: + logger.warning(f"Manual insights refresh failed: {e}") + + asyncio.create_task(_bg_refresh_and_summary()) + + return {"success": True, "refreshed_at": today, "queued": True} except HTTPException: raise @@ -3122,6 +3144,63 @@ async def company_strategy_submit(request: Request): }) +@user_router.post("/company/strategy/import") +async def company_strategy_import(request: Request): + """Import company strategy from a PDF document.""" + session = require_user_session(request) + if not session: + raise HTTPException(status_code=401, detail="Not authenticated") + + if session.account_type != "company" or not session.company_id: + raise HTTPException(status_code=403, detail="Company account required") + + # Check token limit for companies/employees + can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id)) + if not can_create: + raise HTTPException(status_code=429, detail=error_msg) + + try: + form = await request.form() + upload: UploadFile = form.get("file") # type: ignore[assignment] + if not upload: + raise HTTPException(status_code=400, detail="Keine Datei hochgeladen.") + + filename = upload.filename or "" + ext = Path(filename).suffix.lower() + if ext != ".pdf": + raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.") + + file_bytes = await upload.read() + max_bytes = 10 * 1024 * 1024 # 10 MB + if len(file_bytes) > max_bytes: + raise HTTPException(status_code=400, detail="Datei ist zu groß (max 10 MB).") + + extractor = FileExtractor() + try: + text = extractor.extract_text(file_bytes, filename) + except FileExtractionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if len(text) > 50000: + text = text[:50000] + + company = await db.get_company(UUID(session.company_id)) + importer = StrategyImporterAgent() + importer.set_tracking_context( + operation="company_strategy_import", + user_id=session.user_id, + company_id=session.company_id + ) + + strategy = await importer.process(text, company_name=company.name if company else None) + return JSONResponse({"success": True, "strategy": strategy}) + except HTTPException: + raise + except Exception as e: + logger.exception(f"Company strategy import failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @user_router.get("/company/accounts", response_class=HTMLResponse) async def company_accounts_page(request: Request): """Company employee management page.""" @@ -3589,8 +3668,8 @@ async def company_manage_post_types(request: Request, employee_id: str = None): # ==================== EMPLOYEE ROUTES ==================== @user_router.get("/employee/strategy", response_class=HTMLResponse) -async def employee_strategy_page(request: Request): - """Read-only company strategy view for employees.""" +async def employee_strategy_page(request: Request, success: bool = False): + """Employee profile analysis (strategy) view.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) @@ -3599,63 +3678,70 @@ async def employee_strategy_page(request: Request): if session.account_type != "employee" or not session.company_id: return RedirectResponse(url="/", status_code=302) - company = await db.get_company(UUID(session.company_id)) - strategy = company.company_strategy if company else {} - user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) + profile_analysis = await db.get_profile_analysis(user_id) + + analysis = profile_analysis.full_analysis if profile_analysis else {} + if not isinstance(analysis, dict): + analysis = {} + + analysis_json = json.dumps(analysis, ensure_ascii=False, indent=2) return templates.TemplateResponse("employee_strategy.html", { "request": request, "page": "strategy", "session": session, - "company": company, - "strategy": strategy, + "profile_analysis": analysis, + "analysis_json": analysis_json, + "analysis_created_at": profile_analysis.created_at if profile_analysis else None, + "success": success, "profile_picture": profile_picture }) -@user_router.get("/employee/insights", response_class=HTMLResponse) -async def employee_insights_page(request: Request): - """Employee post insights page.""" +@user_router.post("/employee/strategy", response_class=HTMLResponse) +async def employee_strategy_submit(request: Request): + """Save edited profile analysis for employees.""" session = require_user_session(request) if not session: return RedirectResponse(url="/login", status_code=302) - if session.account_type != "employee": + if session.account_type != "employee" or not session.company_id: return RedirectResponse(url="/", status_code=302) + form = await request.form() + raw_json = (form.get("analysis_json") or "").strip() + try: + analysis = json.loads(raw_json) if raw_json else {} + if not isinstance(analysis, dict): + raise ValueError("JSON muss ein Objekt sein.") + + from src.database.models import ProfileAnalysis + user_id = UUID(session.user_id) + analysis_record = ProfileAnalysis( + user_id=user_id, + writing_style=analysis.get("writing_style", {}) or {}, + tone_analysis=analysis.get("tone_analysis", {}) or {}, + topic_patterns=analysis.get("topic_patterns", {}) or {}, + audience_insights=analysis.get("audience_insights", {}) or {}, + full_analysis=analysis + ) + await db.save_profile_analysis(analysis_record) + return RedirectResponse(url="/employee/strategy?success=true", status_code=302) + except Exception as e: user_id = UUID(session.user_id) profile_picture = await get_user_avatar(session, user_id) - linkedin_account = await db.get_linkedin_account(user_id) - - post_insights = {"has_data": False} - if linkedin_account: - try: - from datetime import date, timedelta - since = (date.today() - timedelta(days=90)).isoformat() - insights_posts = await db.get_post_insights_posts(user_id) - insights_daily = await db.get_post_insights_daily(user_id, since_date=since) - post_insights = compute_post_insights(insights_posts, insights_daily) - except Exception as e: - logger.error(f"Error computing post insights: {e}") - - return templates.TemplateResponse("employee_insights.html", { + return templates.TemplateResponse("employee_strategy.html", { "request": request, - "page": "insights", + "page": "strategy", "session": session, - "profile_picture": profile_picture, - "linkedin_account": linkedin_account, - "post_insights": post_insights - }) - except Exception as e: - logger.error(f"Error loading insights: {e}") - return templates.TemplateResponse("employee_insights.html", { - "request": request, - "page": "insights", - "session": session, - "error": str(e) + "profile_analysis": {}, + "analysis_json": raw_json, + "analysis_created_at": None, + "error": f"Fehler beim Speichern: {e}", + "profile_picture": profile_picture })