diff --git a/config/migrate_add_strategy_weight.sql b/config/migrate_add_strategy_weight.sql new file mode 100644 index 0000000..a4354e1 --- /dev/null +++ b/config/migrate_add_strategy_weight.sql @@ -0,0 +1,10 @@ +-- Add strategy_weight column to post_types table +-- This column controls how strongly the company strategy influences AI post generation +-- Range: 0.0 (ignore strategy) to 1.0 (strictly follow strategy) + +ALTER TABLE post_types +ADD COLUMN IF NOT EXISTS strategy_weight FLOAT DEFAULT 0.5 +CHECK (strategy_weight >= 0.0 AND strategy_weight <= 1.0); + +-- Set default value for existing rows +UPDATE post_types SET strategy_weight = 0.5 WHERE strategy_weight IS NULL; diff --git a/src/agents/post_classifier.py b/src/agents/post_classifier.py index 0665936..2f2cabf 100644 --- a/src/agents/post_classifier.py +++ b/src/agents/post_classifier.py @@ -184,8 +184,10 @@ class PostClassifierAgent(BaseAgent): system_prompt = """Du bist ein Content-Analyst, der LinkedIn-Posts in vordefinierte Kategorien einordnet. +WICHTIG: JEDER Post MUSS kategorisiert werden. Du MUSST für jeden Post den am besten passenden Post-Typ wählen, auch wenn die Übereinstimmung nicht perfekt ist. + Analysiere jeden Post und ordne ihn dem passendsten Post-Typ zu. -Wenn kein Typ wirklich passt, gib "null" als post_type_id zurück. +Es ist NICHT erlaubt, "null" als post_type_id zurückzugeben. Bewerte die Zuordnung mit einer Confidence zwischen 0.3 und 1.0: - 0.9-1.0: Sehr sicher, Post passt perfekt zum Typ @@ -209,12 +211,14 @@ Gib ein JSON-Objekt zurück mit diesem Format: "classifications": [ {{ "post_id": "uuid-des-posts", - "post_type_id": "uuid-des-typs oder null", + "post_type_id": "uuid-des-typs (IMMER erforderlich, niemals null)", "confidence": 0.8, "reasoning": "Kurze Begründung" }} ] -}}""" +}} + +WICHTIG: Jeder Post MUSS einen post_type_id bekommen. Wähle den am besten passenden Typ, auch wenn die Übereinstimmung nicht perfekt ist.""" try: response = await self.call_openai( @@ -230,6 +234,8 @@ Gib ein JSON-Objekt zurück mit diesem Format: # Process and validate results valid_results = [] + unclassified_posts = list(posts) # Track which posts still need classification + for c in classifications: post_id = c.get("post_id") post_type_id = c.get("post_type_id") @@ -244,7 +250,8 @@ Gib ein JSON-Objekt zurück mit diesem Format: # Validate post_type_id if post_type_id and post_type_id != "null" and post_type_id not in valid_type_ids: logger.warning(f"Invalid post_type_id in classification: {post_type_id}") - continue + # Fallback: assign to first available type + post_type_id = list(valid_type_ids - {"null"})[0] if valid_type_ids - {"null"} else None if post_type_id and post_type_id != "null": valid_results.append({ @@ -253,6 +260,19 @@ Gib ein JSON-Objekt zurück mit diesem Format: "classification_method": "semantic", "classification_confidence": min(1.0, max(0.3, confidence)) }) + unclassified_posts = [p for p in unclassified_posts if p.id != matching_post.id] + + # FORCE CLASSIFY remaining unclassified posts + if unclassified_posts and post_types: + default_type = post_types[0] # Use first type as fallback + logger.warning(f"Force-classifying {len(unclassified_posts)} posts to default type: {default_type.name}") + for post in unclassified_posts: + valid_results.append({ + "post_id": post.id, + "post_type_id": default_type.id, + "classification_method": "semantic_fallback", + "classification_confidence": 0.3 + }) return valid_results diff --git a/src/agents/researcher.py b/src/agents/researcher.py index 2c6dd51..e6001f6 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -22,7 +22,9 @@ class ResearchAgent(BaseAgent): customer_data: Dict[str, Any], example_posts: List[str] = None, post_type: Any = None, - post_type_analysis: Dict[str, Any] = None + post_type_analysis: Dict[str, Any] = None, + company_strategy: Dict[str, Any] = None, + strategy_weight: float = 0.5 ) -> Dict[str, Any]: """ Research new content topics. @@ -34,6 +36,8 @@ class ResearchAgent(BaseAgent): example_posts: List of the person's actual posts for style reference post_type: Optional PostType object for targeted research post_type_analysis: Optional post type analysis for context + company_strategy: Optional company strategy to align topics with + strategy_weight: Strategy weight (0.0-1.0) controlling strategy influence Returns: Research results with suggested topics @@ -90,7 +94,9 @@ class ResearchAgent(BaseAgent): example_posts=example_posts or [], existing_topics=existing_topics, post_type=post_type, - post_type_analysis=post_type_analysis + post_type_analysis=post_type_analysis, + company_strategy=company_strategy, + strategy_weight=strategy_weight ) response = await self.call_openai( @@ -379,7 +385,9 @@ QUALITÄTSKRITERIEN: example_posts: List[str], existing_topics: List[str], post_type: Any = None, - post_type_analysis: Dict[str, Any] = None + post_type_analysis: Dict[str, Any] = None, + company_strategy: Dict[str, Any] = None, + strategy_weight: float = 0.5 ) -> str: """Transform raw research into personalized, concrete topic suggestions.""" @@ -428,8 +436,11 @@ QUALITÄTSKRITERIEN: post_type_section += "\n**WICHTIG:** Alle Themenvorschläge müssen zu diesem Post-Typ passen!\n" + # Build company strategy section + strategy_section = self._get_strategy_section(company_strategy, strategy_weight) + return f"""AUFGABE: Transformiere die Recherche-Ergebnisse in KONKRETE, PERSONALISIERTE Themenvorschläge. -{post_type_section} +{post_type_section}{strategy_section} === RECHERCHE-ERGEBNISSE (Rohdaten) === {raw_research} @@ -492,6 +503,71 @@ WICHTIG: - Hook-Ideen müssen zum Stil der Beispiel-Posts passen! - Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)""" + def _get_strategy_section(self, company_strategy: Dict[str, Any] = None, strategy_weight: float = 0.5) -> str: + """Build company strategy section for research prompt with weighted influence.""" + if not company_strategy: + return "" + + # Extract strategy elements + mission = company_strategy.get("mission", "") + vision = company_strategy.get("vision", "") + content_pillars = company_strategy.get("content_pillars", []) + target_audience = company_strategy.get("target_audience", "") + dos = company_strategy.get("dos", []) + donts = company_strategy.get("donts", []) + + # Build section only if there's meaningful content + if not any([mission, vision, content_pillars, dos, donts]): + return "" + + # Determine importance level based on strategy_weight + if strategy_weight >= 0.8: + importance = "KRITISCH - STRIKT BEACHTEN" + instruction = "Alle Themen MÜSSEN sich an der Unternehmensstrategie orientieren! Nur Topics vorschlagen, die perfekt zur Strategie passen." + elif strategy_weight >= 0.6: + importance = "WICHTIG - BERÜCKSICHTIGEN" + instruction = "Priorisiere Topics die zur Unternehmensstrategie passen! Die Strategie soll klar erkennbar sein." + elif strategy_weight >= 0.4: + importance = "ORIENTIERUNG" + instruction = "Berücksichtige die Unternehmensstrategie wo sinnvoll - persönliche Expertise hat Priorität." + elif strategy_weight >= 0.2: + importance = "OPTIONAL" + instruction = "Die Strategie dient als Hintergrundinformation - freie Themenauswahl erlaubt." + else: + importance = "NUR REFERENZ" + instruction = "Ignoriere die Strategie weitgehend - Fokus auf persönliche Themen und Expertise." + + section = f""" + +=== UNTERNEHMENSSTRATEGIE ({importance}) === +Strategy Weight: {strategy_weight:.1f} / 1.0 +{instruction} +""" + if mission: + section += f"\nMISSION: {mission}" + if vision: + section += f"\nVISION: {vision}" + if content_pillars: + section += f"\nCONTENT PILLARS: {', '.join(content_pillars)}" + if target_audience: + section += f"\nZIELGRUPPE: {target_audience}" + if dos: + section += f"\n\nEMPFOHLENE THEMEN-RICHTUNGEN:\n" + "\n".join([f" + {d}" for d in dos]) + if donts: + section += f"\n\nZU VERMEIDENDE THEMEN:\n" + "\n".join([f" - {d}" for d in donts]) + + # Weight-specific closing instruction + if strategy_weight >= 0.8: + section += "\n\n**KRITISCH:** Alle Topics müssen zur Unternehmensstrategie passen - keine Ausnahmen!" + elif strategy_weight >= 0.6: + section += "\n\n**WICHTIG:** Stelle sicher, dass die Mehrheit der Topics die Unternehmenswerte widerspiegelt!" + elif strategy_weight >= 0.4: + section += "\n\n**BEACHTE:** Integriere die Strategie subtil - persönliche Expertise bleibt wichtig!" + else: + section += "\n\n**REFERENZ:** Die Strategie dient nur als Orientierung - persönliche Topics haben Vorrang!" + + return section + "\n" + def _get_structure_prompt( self, raw_research: str, diff --git a/src/agents/writer.py b/src/agents/writer.py index 9fe0369..742bc76 100644 --- a/src/agents/writer.py +++ b/src/agents/writer.py @@ -30,7 +30,8 @@ class WriterAgent(BaseAgent): post_type_analysis: Optional[Dict[str, Any]] = None, user_thoughts: str = "", selected_hook: str = "", - company_strategy: Optional[Dict[str, Any]] = None + company_strategy: Optional[Dict[str, Any]] = None, + strategy_weight: float = 0.5 ) -> str: """ Write a LinkedIn post. @@ -46,6 +47,7 @@ class WriterAgent(BaseAgent): post_type: Optional PostType object for type-specific writing post_type_analysis: Optional analysis of the post type company_strategy: Optional company strategy to consider during writing + strategy_weight: Strategy weight (0.0-1.0) controlling how strongly the company strategy influences post generation Returns: Written LinkedIn post @@ -65,7 +67,8 @@ class WriterAgent(BaseAgent): post_type_analysis=post_type_analysis, user_thoughts=user_thoughts, selected_hook=selected_hook, - company_strategy=company_strategy + company_strategy=company_strategy, + strategy_weight=strategy_weight ) else: logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}") @@ -90,7 +93,8 @@ class WriterAgent(BaseAgent): post_type_analysis=post_type_analysis, user_thoughts=user_thoughts, selected_hook=selected_hook, - company_strategy=company_strategy + company_strategy=company_strategy, + strategy_weight=strategy_weight ) else: return await self._write_single_draft( @@ -102,7 +106,8 @@ class WriterAgent(BaseAgent): post_type_analysis=post_type_analysis, user_thoughts=user_thoughts, selected_hook=selected_hook, - company_strategy=company_strategy + company_strategy=company_strategy, + strategy_weight=strategy_weight ) def _select_example_posts( @@ -236,7 +241,8 @@ class WriterAgent(BaseAgent): post_type_analysis: Optional[Dict[str, Any]] = None, user_thoughts: str = "", selected_hook: str = "", - company_strategy: Optional[Dict[str, Any]] = None + company_strategy: Optional[Dict[str, Any]] = None, + strategy_weight: float = 0.5 ) -> str: """ Generate multiple drafts and select the best one. @@ -249,6 +255,7 @@ class WriterAgent(BaseAgent): post_type: Optional PostType object post_type_analysis: Optional post type analysis company_strategy: Optional company strategy to consider + strategy_weight: Strategy weight (0.0-1.0) for company strategy influence Returns: Best selected draft @@ -256,7 +263,7 @@ class WriterAgent(BaseAgent): num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5 logger.info(f"Generating {num_drafts} drafts for selection") - system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy) + system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight) # Generate drafts in parallel with different temperatures/approaches draft_configs = [ @@ -500,7 +507,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format: post_type_analysis: Optional[Dict[str, Any]] = None, user_thoughts: str = "", selected_hook: str = "", - company_strategy: Optional[Dict[str, Any]] = None + company_strategy: Optional[Dict[str, Any]] = None, + strategy_weight: float = 0.5 ) -> str: """Write a single draft (original behavior).""" # Select examples if not already selected @@ -515,7 +523,7 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format: elif len(selected_examples) > 3: selected_examples = random.sample(selected_examples, 3) - system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy) + system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight) user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook) # OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil @@ -536,7 +544,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format: learned_lessons: Optional[Dict[str, Any]] = None, post_type: Any = None, post_type_analysis: Optional[Dict[str, Any]] = None, - company_strategy: Optional[Dict[str, Any]] = None + company_strategy: Optional[Dict[str, Any]] = None, + strategy_weight: float = 0.5 ) -> str: """Get system prompt for writer - orientiert an bewährten n8n-Prompts.""" # Extract key profile information @@ -788,13 +797,13 @@ Vermeide IMMER diese KI-typischen Muster: - Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten {lessons_section} {post_type_section} -{self._get_company_strategy_section(company_strategy)} +{self._get_company_strategy_section(company_strategy, strategy_weight)} DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar. Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post".""" - def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None) -> str: - """Build the company strategy section for the system prompt.""" + def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None, strategy_weight: float = 0.5) -> str: + """Build the company strategy section for the system prompt with weighted influence.""" if not company_strategy: return "" @@ -812,11 +821,29 @@ Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post" if not any([mission, vision, brand_voice, content_pillars, dos, donts]): return "" - section = """ + # Determine importance level and instruction based on strategy_weight + if strategy_weight >= 0.8: + importance = "KRITISCH WICHTIG - STRIKT BEFOLGEN" + instruction = "Der Post MUSS sich eng an die Unternehmensstrategie halten! Die Strategy-Guidelines haben höchste Priorität." + elif strategy_weight >= 0.6: + importance = "WICHTIG - BEACHTEN" + instruction = "Integriere die Unternehmenswerte deutlich, aber bewahre Authentizität! Die Strategie soll klar erkennbar sein." + elif strategy_weight >= 0.4: + importance = "ALS KONTEXT BERÜCKSICHTIGEN" + instruction = "Berücksichtige die Strategie wo sinnvoll - Persönlicher Stil hat Priorität! Subtile Integration genügt." + elif strategy_weight >= 0.2: + importance = "OPTIONALE ORIENTIERUNG" + instruction = "Die Strategie dient als Hintergrundinformation - freie Interpretation erlaubt! Fokus auf Authentizität." + else: + importance = "NUR ALS REFERENZ" + instruction = "Ignoriere die Strategie weitgehend - voller Fokus auf persönlichen Stil! Strategy-Guidelines sind optional." -8. UNTERNEHMENSSTRATEGIE (WICHTIG - IMMER BEACHTEN!): + section = f""" -Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Autors bleiben! +8. UNTERNEHMENSSTRATEGIE ({importance}): + +Strategy Weight: {strategy_weight:.1f} / 1.0 +{instruction} """ if mission: section += f"\nMISSION: {mission}" @@ -835,7 +862,15 @@ Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Aut if donts: section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in donts]) - section += "\n\nWICHTIG: Integriere die Unternehmenswerte subtil - der Post soll nicht wie eine Werbung klingen!" + # Weight-specific closing instruction + if strategy_weight >= 0.8: + section += "\n\nKRITISCH: Die Unternehmensstrategie ist nicht verhandelbar - jeder Post muss diese widerspiegeln!" + elif strategy_weight >= 0.6: + section += "\n\nWICHTIG: Stelle sicher, dass die Unternehmenswerte deutlich erkennbar sind!" + elif strategy_weight >= 0.4: + section += "\n\nINTEGRIERE: Berücksichtige die Strategie subtil - authentischer Stil bleibt wichtig!" + else: + section += "\n\nREFERENZ: Die Strategie dient nur als Orientierung - persönlicher Stil hat Vorrang!" return section diff --git a/src/database/models.py b/src/database/models.py index 2df83a1..013eaf5 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -226,6 +226,7 @@ class PostType(DBModel): analysis_generated_at: Optional[datetime] = None analyzed_post_count: int = 0 is_active: bool = True + strategy_weight: float = 0.5 class LinkedInProfile(DBModel): diff --git a/src/orchestrator.py b/src/orchestrator.py index 6ff4fd4..92ea072 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -342,11 +342,13 @@ class WorkflowOrchestrator: # Get post type context if specified post_type = None post_type_analysis = None + strategy_weight = 0.5 # Default strategy weight if post_type_id: post_type = await db.get_post_type(post_type_id) if post_type: post_type_analysis = post_type.analysis - logger.info(f"Targeting research for post type: {post_type.name}") + strategy_weight = post_type.strategy_weight + logger.info(f"Targeting research for post type: {post_type.name} with strategy weight {strategy_weight:.1f}") def report_progress(message: str, step: int, total: int = 4): if progress_callback: @@ -358,6 +360,15 @@ class WorkflowOrchestrator: if not profile_analysis: raise ValueError("Profile analysis not found. Run initial setup first.") + # Step 1.5: Load company strategy if user belongs to a company + company_strategy = None + profile = await db.get_profile(user_id) + if profile and profile.company_id: + company = await db.get_company(profile.company_id) + if company and company.company_strategy: + company_strategy = company.company_strategy + logger.info(f"Loaded company strategy for research: {company.name}") + # Step 2: Get ALL existing topics (from multiple sources to avoid repetition) report_progress("Lade existierende Topics...", 2) existing_topics = set() @@ -384,9 +395,6 @@ class WorkflowOrchestrator: existing_topics = list(existing_topics) logger.info(f"Found {len(existing_topics)} existing topics to avoid") - # Get profile data - profile = await db.get_profile(user_id) - # Get example posts to understand the person's actual content style # If post_type_id is specified, only use posts of that type if post_type_id: @@ -409,7 +417,9 @@ class WorkflowOrchestrator: customer_data=profile.metadata, example_posts=example_post_texts, post_type=post_type, - post_type_analysis=post_type_analysis + post_type_analysis=post_type_analysis, + company_strategy=company_strategy, + strategy_weight=strategy_weight ) # Step 4: Save research results @@ -582,11 +592,14 @@ class WorkflowOrchestrator: # Get post type info if specified post_type = None post_type_analysis = None + strategy_weight = 0.5 # Default strategy weight if post_type_id: post_type = await db.get_post_type(post_type_id) - if post_type and post_type.analysis: - post_type_analysis = post_type.analysis - logger.info(f"Using post type '{post_type.name}' for writing") + if post_type: + if post_type.analysis: + post_type_analysis = post_type.analysis + strategy_weight = post_type.strategy_weight # Extract strategy weight from post type + logger.info(f"Using post type '{post_type.name}' with strategy weight {strategy_weight:.1f}") # Load user's real posts as style examples # If post_type_id is specified, only use posts of that type @@ -642,7 +655,8 @@ class WorkflowOrchestrator: post_type_analysis=post_type_analysis, user_thoughts=user_thoughts, selected_hook=selected_hook, - company_strategy=company_strategy # NEW: Pass company strategy + company_strategy=company_strategy, # Pass company strategy + strategy_weight=strategy_weight # NEW: Pass strategy weight ) else: # Revision based on feedback - pass full critic result for structured changes @@ -660,7 +674,8 @@ class WorkflowOrchestrator: post_type_analysis=post_type_analysis, user_thoughts=user_thoughts, selected_hook=selected_hook, - company_strategy=company_strategy # NEW: Pass company strategy + company_strategy=company_strategy, # Pass company strategy + strategy_weight=strategy_weight # NEW: Pass strategy weight ) writer_versions.append(current_post) diff --git a/src/services/background_jobs.py b/src/services/background_jobs.py index fa825ff..8abea2e 100644 --- a/src/services/background_jobs.py +++ b/src/services/background_jobs.py @@ -396,6 +396,101 @@ async def run_post_categorization(user_id: UUID, job_id: str): ) +async def run_post_recategorization(user_id: UUID, job_id: str): + """Re-categorize ALL LinkedIn posts (including already categorized ones).""" + from src.database.client import DatabaseClient + from src.agents.post_classifier import PostClassifierAgent + import asyncio + + db = DatabaseClient() + + try: + await job_manager.update_job( + job_id, + status=JobStatus.RUNNING, + progress=5, + message="Lade alle LinkedIn Posts..." + ) + + # Get ALL LinkedIn posts (not just unclassified) + posts = await db.get_linkedin_posts(user_id) + post_types = await db.get_post_types(user_id) + + if not posts: + await job_manager.update_job( + job_id, + status=JobStatus.COMPLETED, + progress=100, + message="Keine Posts gefunden!" + ) + return + + if not post_types: + await job_manager.update_job( + job_id, + status=JobStatus.FAILED, + error="Keine Post-Typen definiert" + ) + return + + # FIRST: Mark all posts as uncategorized before re-categorizing + await job_manager.update_job( + job_id, + progress=15, + message=f"Markiere {len(posts)} Posts als unkategorisiert..." + ) + + # Set post_type_id to NULL for all posts + for post in posts: + try: + await asyncio.to_thread( + lambda p=post: db.client.table("linkedin_posts").update({ + "post_type_id": None + }).eq("id", str(p.id)).execute() + ) + except Exception as e: + logger.warning(f"Failed to unclassify post {post.id}: {e}") + + logger.info(f"Marked {len(posts)} posts as uncategorized") + + await job_manager.update_job( + job_id, + progress=30, + message=f"Re-kategorisiere {len(posts)} Posts..." + ) + + # Run classification on ALL posts + classifier = PostClassifierAgent() + classifications = await classifier.process(posts, post_types) + + await job_manager.update_job( + job_id, + progress=70, + message="Speichere neue Kategorisierungen..." + ) + + # Save classifications + if classifications: + await db.update_posts_classification_bulk(classifications) + + await job_manager.update_job( + job_id, + status=JobStatus.COMPLETED, + progress=100, + message=f"{len(classifications)} Posts re-kategorisiert!" + ) + + logger.info(f"Post re-categorization completed for user {user_id}: {len(classifications)} posts") + + except Exception as e: + logger.error(f"Post re-categorization failed: {e}") + await job_manager.update_job( + job_id, + status=JobStatus.FAILED, + error=str(e) + ) + + async def run_post_type_analysis(user_id: UUID, job_id: str): """Run post type analysis in background.""" from src.database.client import DatabaseClient diff --git a/src/web/templates/user/company_accounts.html b/src/web/templates/user/company_accounts.html index 0a093be..63c3eb6 100644 --- a/src/web/templates/user/company_accounts.html +++ b/src/web/templates/user/company_accounts.html @@ -77,11 +77,11 @@ {% for employee in employees %}
-
+
{% if employee.linkedin_picture %} - {{ employee.linkedin_name }} + {% else %} - {{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }} + {{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }} {% endif %}
diff --git a/src/web/templates/user/employee_dashboard.html b/src/web/templates/user/employee_dashboard.html index e7d398e..1709b27 100644 --- a/src/web/templates/user/employee_dashboard.html +++ b/src/web/templates/user/employee_dashboard.html @@ -74,7 +74,7 @@

Schnellzugriff

-
+
diff --git a/src/web/templates/user/employee_post_types.html b/src/web/templates/user/employee_post_types.html new file mode 100644 index 0000000..bbee6d7 --- /dev/null +++ b/src/web/templates/user/employee_post_types.html @@ -0,0 +1,955 @@ +{% extends "base.html" %} + +{% block title %}Post-Typen verwalten{% endblock %} + +{% block content %} +
+ +
+
+ + + + + +
+

Post-Typen verwalten

+

Definiere und konfiguriere deine Post-Kategorien

+
+ +
+
+ + + {% if has_strategy %} +
+
+ + + +
+

Strategy Weight erklärt

+

+ Der Strategy Weight steuert, wie stark die KI bei der Post-Generierung die Unternehmensstrategie berücksichtigt: + 0.0 = komplett ignorieren | + 0.5 = ausgewogen | + 1.0 = strikt befolgen +

+
+
+
+ {% endif %} + + + {% if not post_types_with_counts %} +
+
+ + + +
+

Noch keine Post-Typen definiert

+

Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.

+ +
+ {% endif %} + + +
+ +
+ + + +
+ + + + + {% if post_types_with_counts %} +
+ +
+ {% endif %} +
+ + + + + + + + + + + + + + +{% endblock %} diff --git a/src/web/templates/user/employee_post_types.html.backup b/src/web/templates/user/employee_post_types.html.backup new file mode 100644 index 0000000..d167040 --- /dev/null +++ b/src/web/templates/user/employee_post_types.html.backup @@ -0,0 +1,706 @@ +{% extends "base.html" %} + +{% block title %}Post-Typen verwalten{% endblock %} + +{% block content %} +
+ +
+
+

Post-Typen verwalten

+

Definiere und konfiguriere deine Post-Kategorien

+
+ +
+ + + {% if has_strategy %} +
+
+ + + +
+

Strategy Weight erklärt

+

+ Der Strategy Weight steuert, wie stark die KI bei der Post-Generierung die Unternehmensstrategie berücksichtigt: + 0.0 = komplett ignorieren | + 0.5 = ausgewogen | + 1.0 = strikt befolgen +

+
+
+
+ {% endif %} + + + {% if not post_types_with_counts %} +
+
+ + + +
+

Noch keine Post-Typen definiert

+

Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.

+ +
+ {% endif %} + + +
+ {% for item in post_types_with_counts %} + {% set pt = item.post_type %} +
+
+
+

{{ pt.name }}

+ {% if pt.description %} +

{{ pt.description }}

+ {% endif %} +
+
+ + +
+
+ + +
+ + +
+ Ignorieren (0.0) + Ausgewogen (0.5) + Strikt (1.0) +
+
+ + +
+ + + + {{ item.post_count }} Posts zugeordnet +
+
+ {% endfor %} +
+ + + {% if post_types_with_counts %} +
+ +
+ {% endif %} +
+ + + + + + + + + + + +{% endblock %} diff --git a/src/web/templates/user/employee_post_types_new.js b/src/web/templates/user/employee_post_types_new.js new file mode 100644 index 0000000..dae7e09 --- /dev/null +++ b/src/web/templates/user/employee_post_types_new.js @@ -0,0 +1,355 @@ +// State management - all changes happen in memory until Save +let postTypesState = []; +let originalPostTypesJSON = ''; +let hasUnsavedChanges = false; +let isEditMode = false; +let currentEditId = null; +let tempIdCounter = 0; + +// Initialize state from server data +function initializeState() { + const initialData = document.getElementById('initial-post-types'); + if (!initialData) return; + + try { + const data = JSON.parse(initialData.textContent); + postTypesState = data.map(item => ({ + id: item.post_type.id, + name: item.post_type.name, + description: item.post_type.description, + strategy_weight: item.post_type.strategy_weight, + post_count: item.post_count, + _status: 'existing', // 'existing', 'new', 'modified', 'deleted' + _originalWeight: item.post_type.strategy_weight + })); + originalPostTypesJSON = JSON.stringify(postTypesState); + renderPostTypesList(); + updateSaveButton(); + } catch (e) { + console.error('Failed to initialize state:', e); + } +} + +// Check if there are unsaved changes +function checkForChanges() { + const currentJSON = JSON.stringify(postTypesState.map(pt => ({ + id: pt.id, + name: pt.name, + description: pt.description, + strategy_weight: pt.strategy_weight, + _status: pt._status + }))); + hasUnsavedChanges = currentJSON !== originalPostTypesJSON; + updateSaveButton(); +} + +// Render the post types list from state +function renderPostTypesList() { + const container = document.getElementById('post-types-list'); + if (!container) return; + + const activePostTypes = postTypesState.filter(pt => pt._status !== 'deleted'); + + if (activePostTypes.length === 0) { + container.innerHTML = ` +
+
+ + + +
+

Noch keine Post-Typen definiert

+

Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.

+
+ `; + return; + } + + container.innerHTML = activePostTypes.map(pt => { + const isNew = pt._status === 'new'; + const isModified = pt._status === 'modified'; + const statusBadge = isNew ? 'Neu' : + isModified ? 'Geändert' : ''; + + return ` +
+
+
+

${escapeHtml(pt.name)}${statusBadge}

+ ${pt.description ? `

${escapeHtml(pt.description)}

` : ''} +
+
+ + +
+
+ +
+ + +
+ Ignorieren (0.0) + Ausgewogen (0.5) + Strikt (1.0) +
+
+ +
+ + + + ${pt.post_count || 0} Posts zugeordnet +
+
+ `; + }).join(''); +} + +// Update save button state +function updateSaveButton() { + const saveBtn = document.getElementById('save-all-btn'); + if (!saveBtn) return; + + const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted'); + const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted'); + const hasWeightChanges = postTypesState.some(pt => pt._status === 'modified' || (pt._status === 'existing' && pt.strategy_weight !== pt._originalWeight)); + + if (hasUnsavedChanges || hasStructuralChanges || hasWeightChanges) { + saveBtn.disabled = false; + saveBtn.classList.add('ring-4', 'ring-brand-highlight/50'); + + const btnText = saveBtn.querySelector('span'); + if (btnText) { + btnText.textContent = hasStructuralChanges ? 'Speichern & Re-Kategorisieren' : 'Änderungen speichern'; + } + } else { + saveBtn.disabled = true; + saveBtn.classList.remove('ring-4', 'ring-brand-highlight/50'); + } +} + +// Create new post type (only in state) +function createPostTypeState(name, description, strategyWeight) { + const tempId = `temp_${++tempIdCounter}`; + postTypesState.push({ + id: tempId, + name: name, + description: description, + strategy_weight: strategyWeight, + post_count: 0, + _status: 'new', + _originalWeight: strategyWeight + }); + + renderPostTypesList(); + checkForChanges(); + showToast('Post-Typ hinzugefügt (noch nicht gespeichert)', 'info'); +} + +// Edit post type (only in state) +function editPostTypeState(id) { + const postType = postTypesState.find(pt => pt.id === id); + if (!postType) return; + + document.getElementById('modal-title').textContent = 'Post-Typ bearbeiten'; + document.getElementById('edit-id').value = id; + document.getElementById('edit-name').value = postType.name; + document.getElementById('edit-description').value = postType.description || ''; + document.getElementById('edit-weight').value = postType.strategy_weight; + updateModalWeightDisplay(postType.strategy_weight); + isEditMode = true; + currentEditId = id; + + document.getElementById('modal-step-1').classList.add('hidden'); + document.getElementById('edit-form').classList.remove('hidden'); + document.getElementById('edit-modal').classList.remove('hidden'); + document.getElementById('edit-modal').classList.add('flex'); +} + +// Update post type in state +function updatePostTypeState(id, name, description, strategyWeight) { + const postType = postTypesState.find(pt => pt.id === id); + if (!postType) return; + + postType.name = name; + postType.description = description; + postType.strategy_weight = strategyWeight; + + if (postType._status === 'existing') { + postType._status = 'modified'; + } + + renderPostTypesList(); + checkForChanges(); + showToast('Post-Typ aktualisiert (noch nicht gespeichert)', 'info'); +} + +// Delete post type (only in state) +function deletePostTypeState(id) { + const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted'); + + // Prevent deleting last post type + if (activeTypes.length === 1) { + showToast('Du musst mindestens einen Post-Typ behalten!', 'error'); + return; + } + + const postType = postTypesState.find(pt => pt.id === id); + if (!postType) return; + + if (postType.post_count > 0) { + if (!confirm(`Dieser Post-Typ hat ${postType.post_count} zugeordnete Posts. Wirklich löschen?`)) { + return; + } + } + + if (postType._status === 'new') { + // Remove completely if it was never saved + postTypesState = postTypesState.filter(pt => pt.id !== id); + } else { + // Mark as deleted + postType._status = 'deleted'; + } + + renderPostTypesList(); + checkForChanges(); + showToast('Post-Typ gelöscht (noch nicht gespeichert)', 'info'); +} + +// Update strategy weight in state +function updateStrategyWeightState(id, value) { + const postType = postTypesState.find(pt => pt.id === id); + if (!postType) return; + + const numValue = parseFloat(value); + postType.strategy_weight = numValue; + + document.getElementById(`weight-value-${id}`).textContent = numValue.toFixed(1); + + if (postType._status === 'existing' && numValue !== postType._originalWeight) { + postType._status = 'modified'; + } + + checkForChanges(); +} + +// Save all changes to database +async function saveAllChanges() { + const saveBtn = document.getElementById('save-all-btn'); + if (!saveBtn) return; + + const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted'); + if (activeTypes.length === 0) { + showToast('Du musst mindestens einen Post-Typ behalten!', 'error'); + return; + } + + saveBtn.disabled = true; + showToast('Speichere Änderungen...', 'info'); + + try { + const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted'); + + // 1. Delete removed post types + const deletedTypes = postTypesState.filter(pt => pt._status === 'deleted' && !pt.id.startsWith('temp_')); + for (const pt of deletedTypes) { + await fetch(`/api/employee/post-types/${pt.id}`, { method: 'DELETE' }); + } + + // 2. Create new post types + const newTypes = postTypesState.filter(pt => pt._status === 'new'); + for (const pt of newTypes) { + const response = await fetch('/api/employee/post-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: pt.name, + description: pt.description, + strategy_weight: pt.strategy_weight + }) + }); + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to create post type'); + } + } + + // 3. Update modified post types + const modifiedTypes = postTypesState.filter(pt => pt._status === 'modified' && !pt.id.startsWith('temp_')); + for (const pt of modifiedTypes) { + await fetch(`/api/employee/post-types/${pt.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: pt.name, + description: pt.description, + strategy_weight: pt.strategy_weight + }) + }); + } + + // 4. Trigger save-all with structural changes flag + const saveAllResponse = await fetch('/api/employee/post-types/save-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ has_structural_changes: hasStructuralChanges }) + }); + + const saveAllResult = await saveAllResponse.json(); + + if (saveAllResult.success) { + if (saveAllResult.recategorized) { + showToast('Änderungen gespeichert - Rekategorisierung läuft...', 'success'); + } else { + showToast('Änderungen erfolgreich gespeichert', 'success'); + } + + // Reload page to get fresh data + setTimeout(() => window.location.reload(), 1000); + } else { + throw new Error(saveAllResult.error || 'Save failed'); + } + + } catch (error) { + console.error('Error saving changes:', error); + showToast('Fehler beim Speichern: ' + error.message, 'error'); + saveBtn.disabled = false; + } +} + +// Helper functions +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', () => { + initializeState(); + + // Warn before leaving with unsaved changes + window.addEventListener('beforeunload', (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + } + }); +}); diff --git a/src/web/templates/user/post_types.html b/src/web/templates/user/post_types.html index e0b59ac..2009650 100644 --- a/src/web/templates/user/post_types.html +++ b/src/web/templates/user/post_types.html @@ -10,10 +10,19 @@

Post-Typen

Verwalte und kategorisiere deine LinkedIn-Posts

- +
+ + + + + + Post-Typen verwalten + + +
diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 019bda3..6271f8a 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -34,7 +34,7 @@ from src.services.email_service import ( from src.services.background_jobs import ( job_manager, JobType, JobStatus, run_post_scraping, run_profile_analysis, run_post_categorization, run_post_type_analysis, - run_full_analysis_pipeline + run_full_analysis_pipeline, run_post_recategorization ) from src.services.storage_service import storage @@ -2948,6 +2948,310 @@ async def employee_strategy_page(request: Request): }) +# ============================================================================ +# EMPLOYEE POST TYPES MANAGEMENT +# ============================================================================ + +@user_router.get("/post-types/manage") +async def employee_post_types_page(request: Request): + """Employee post types management page with strategy weight configuration.""" + session = require_user_session(request) + if not session: + return RedirectResponse(url="/login", status_code=302) + + if session.account_type != "employee": + raise HTTPException(status_code=403, detail="Only employees can access this page") + + try: + user_id = UUID(session.user_id) + + # Get all active post types for this employee + post_types = await db.get_post_types(user_id, active_only=True) + + # Count posts for each post type and convert to JSON-serializable format + post_types_with_counts = [] + for pt in post_types: + posts = await db.get_posts_by_type(user_id, pt.id) + post_types_with_counts.append({ + "post_type": { + "id": str(pt.id), + "name": pt.name, + "description": pt.description, + "strategy_weight": pt.strategy_weight, + "is_active": pt.is_active + }, + "post_count": len(posts) + }) + + # Check if company strategy exists + company_strategy = None + has_strategy = False + if session.company_id: + company_id = UUID(session.company_id) + company = await db.get_company(company_id) + if company and company.company_strategy: + company_strategy = company.company_strategy + has_strategy = True + + profile_picture = await get_user_avatar(session, user_id) + + # Convert to JSON string for JavaScript + import json + post_types_json = json.dumps(post_types_with_counts) + + logger.info(f"Generated JSON for {len(post_types_with_counts)} post types") + logger.info(f"JSON length: {len(post_types_json)} characters") + logger.info(f"JSON preview: {post_types_json[:200] if len(post_types_json) > 200 else post_types_json}") + + return templates.TemplateResponse("employee_post_types.html", { + "request": request, + "page": "post_types", + "session": session, + "post_types_with_counts": post_types_with_counts, + "post_types_json": post_types_json, + "has_strategy": has_strategy, + "company_strategy": company_strategy, + "profile_picture": profile_picture + }) + + except Exception as e: + logger.error(f"Error loading employee post types page: {e}") + profile_picture = await get_user_avatar(session, UUID(session.user_id)) + import json + return templates.TemplateResponse("employee_post_types.html", { + "request": request, + "page": "post_types", + "session": session, + "post_types_with_counts": [], + "post_types_json": json.dumps([]), + "has_strategy": False, + "company_strategy": None, + "error": str(e), + "profile_picture": profile_picture + }) + + +@user_router.post("/api/employee/post-types") +async def create_employee_post_type(request: Request, background_tasks: BackgroundTasks): + """Create a new post type for an employee.""" + session = require_user_session(request) + if not session: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + if session.account_type != "employee": + return JSONResponse({"error": "Only employees can create post types"}, status_code=403) + + try: + data = await request.json() + user_id = UUID(session.user_id) + + # Validate required fields + name_raw = data.get("name") + name = name_raw.strip() if name_raw else "" + if not name or len(name) < 3: + return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) + + description_raw = data.get("description") + description = description_raw.strip() if description_raw else "" + strategy_weight = float(data.get("strategy_weight", 0.5)) + + # Validate strategy_weight range + if not (0.0 <= strategy_weight <= 1.0): + return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) + + # Check if an inactive post type with this name already exists + existing_inactive = None + try: + all_post_types = await db.get_post_types(user_id, active_only=False) + existing_inactive = next((pt for pt in all_post_types if pt.name.lower() == name.lower() and not pt.is_active), None) + except Exception as check_error: + logger.warning(f"Could not check for inactive post types: {check_error}") + + if existing_inactive: + # Reactivate the existing post type instead of creating a new one + await db.update_post_type(existing_inactive.id, { + "is_active": True, + "description": description if description else existing_inactive.description, + "strategy_weight": strategy_weight + }) + created_post_type = await db.get_post_type(existing_inactive.id) + logger.info(f"Reactivated post type '{name}' for user {user_id}") + else: + # Create new post type + from src.database.models import PostType + post_type = PostType( + user_id=user_id, + name=name, + description=description if description else None, + strategy_weight=strategy_weight, + is_active=True + ) + + created_post_type = await db.create_post_type(post_type) + logger.info(f"Created post type '{name}' for user {user_id}") + + return JSONResponse({ + "success": True, + "post_type": { + "id": str(created_post_type.id), + "name": created_post_type.name, + "description": created_post_type.description, + "strategy_weight": created_post_type.strategy_weight + } + }) + + except Exception as e: + logger.error(f"Error creating post type: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + +@user_router.put("/api/employee/post-types/{post_type_id}") +async def update_employee_post_type(request: Request, post_type_id: str): + """Update an existing post type.""" + session = require_user_session(request) + if not session: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + if session.account_type != "employee": + return JSONResponse({"error": "Only employees can update post types"}, status_code=403) + + try: + user_id = UUID(session.user_id) + pt_id = UUID(post_type_id) + + # Check ownership + post_type = await db.get_post_type(pt_id) + if not post_type or post_type.user_id != user_id: + return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) + + data = await request.json() + updates = {} + + # Update name if provided + if "name" in data: + name_raw = data["name"] + name = name_raw.strip() if name_raw else "" + if not name or len(name) < 3: + return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400) + updates["name"] = name + + # Update description if provided + if "description" in data: + desc_raw = data["description"] + updates["description"] = desc_raw.strip() if desc_raw else None + + # Update strategy_weight if provided + if "strategy_weight" in data: + strategy_weight = float(data["strategy_weight"]) + if not (0.0 <= strategy_weight <= 1.0): + return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400) + updates["strategy_weight"] = strategy_weight + + # Apply updates + if updates: + await db.update_post_type(pt_id, updates) + + return JSONResponse({"success": True}) + + except Exception as e: + logger.error(f"Error updating post type: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + +@user_router.delete("/api/employee/post-types/{post_type_id}") +async def delete_employee_post_type(request: Request, post_type_id: str, background_tasks: BackgroundTasks): + """Soft delete a post type (set is_active = False).""" + session = require_user_session(request) + if not session: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + if session.account_type != "employee": + return JSONResponse({"error": "Only employees can delete post types"}, status_code=403) + + try: + user_id = UUID(session.user_id) + pt_id = UUID(post_type_id) + + # Check ownership + post_type = await db.get_post_type(pt_id) + if not post_type or post_type.user_id != user_id: + return JSONResponse({"error": "Post type not found or access denied"}, status_code=404) + + # Count affected posts + posts = await db.get_posts_by_type(user_id, pt_id) + affected_count = len(posts) + + # Soft delete + await db.update_post_type(pt_id, {"is_active": False}) + + logger.info(f"Deleted post type '{post_type.name}' for user {user_id}") + + return JSONResponse({ + "success": True, + "affected_posts": affected_count + }) + + except Exception as e: + logger.error(f"Error deleting post type: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + +@user_router.post("/api/employee/post-types/save-all") +async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks): + """Save all changes and conditionally trigger re-categorization based on structural changes.""" + session = require_user_session(request) + if not session: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + if session.account_type != "employee": + return JSONResponse({"error": "Only employees can trigger re-analysis"}, status_code=403) + + try: + data = await request.json() + has_structural_changes = data.get("has_structural_changes", False) + + user_id = UUID(session.user_id) + user_id_str = str(user_id) + + # Only trigger re-categorization and analysis if there were structural changes + if has_structural_changes: + # Create background job for post re-categorization (ALL posts) + categorization_job = job_manager.create_job( + job_type=JobType.POST_CATEGORIZATION, + user_id=user_id_str + ) + + # Create background job for post type analysis + analysis_job = job_manager.create_job( + job_type=JobType.POST_TYPE_ANALYSIS, + user_id=user_id_str + ) + + # Start background tasks + background_tasks.add_task(run_post_recategorization, user_id, categorization_job.id) + background_tasks.add_task(run_post_type_analysis, user_id, analysis_job.id) + + logger.info(f"Started re-analysis jobs for user {user_id}: categorization={categorization_job.id}, analysis={analysis_job.id}") + + return JSONResponse({ + "success": True, + "recategorized": True, + "categorization_job_id": str(categorization_job.id), + "analysis_job_id": str(analysis_job.id) + }) + else: + logger.info(f"No structural changes for user {user_id}, skipping re-analysis") + return JSONResponse({ + "success": True, + "recategorized": False, + "message": "Nur Weight-Updates, keine Rekategorisierung notwendig" + }) + + except Exception as e: + logger.error(f"Error in save-all: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + @user_router.post("/api/company/invite") async def send_company_invitation(request: Request): """Send invitation to a new employee."""