From b5f12dd0fac56bdaacfe55c1ceec62bf3f1fd78b Mon Sep 17 00:00:00 2001 From: Floke Date: Tue, 20 Jan 2026 15:35:26 +0000 Subject: [PATCH] fix(explorer): resolve notion sync, add debug logging, and fix UI display for industries v0.6.1 --- MIGRATION_PLAN.md | 8 +- company-explorer/backend/config.py | 2 +- company-explorer/backend/database.py | 16 +- .../backend/scripts/sync_notion_industries.py | 47 ++-- company-explorer/frontend/src/App.tsx | 2 +- .../src/components/RoboticsSettings.tsx | 239 +++++++----------- 6 files changed, 129 insertions(+), 185 deletions(-) diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index 1ee7c984..374cd794 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -88,6 +88,12 @@ Wir kapseln das neue Projekt vollständig ab ("Fork & Clean"). * `description` (Text - Abgrenzung/Definition) * `is_focus` (Boolean) * `primary_category_id` (FK -> robotics_categories.id) +* `metric_type` (String: `Unit_Count`, `Area_in`, `Area_out` - Art der Metrik zur Größenbestimmung) +* `min_requirement` (Float, nullable - Minimaler Schwellenwert für Signal-Relevanz) +* `whale_threshold` (Float, nullable - Schwellenwert, ab dem ein Account als "Whale" gilt) +* `proxy_factor` (Float, nullable - Multiplikator für die Standardisierungslogik) +* `scraper_keywords` (JSON-Array von Strings - Keywords für den Scraper zur Metrik-Erkennung) +* `standardization_logic` (String - Formel zur Standardisierung der Metrik, z.B. "wert * 25m²") ### Tabelle `job_role_mappings` (Rollen-Logik) * `id` (PK) @@ -152,7 +158,7 @@ Contacts stehen in 1:n Beziehung zu Accounts. Accounts können einen "Primary Co ## 7. Historie & Fixes (Jan 2026) -* **[UPGRADE] v0.6.0: Notion Single Source of Truth (Jan 19, 2026)** +* **[UPGRADE] v0.6.1: Notion Single Source of Truth (Jan 20, 2026)** * **Notion SSoT:** Umstellung der Branchenverwaltung (`Industries`) und Robotik-Kategorien auf Notion. Lokale Änderungen im Web-Interface sind für synchronisierte Felder deaktiviert, um die Datenintegrität zu wahren. * **Dynamische Klassifizierung:** Der `ClassificationService` lädt die `allowed_industries` nun direkt aus der Datenbank, die wiederum via Sync-Skript aus Notion befüllt wird. * **Erweiterte Datenmodelle:** Die Datenbank wurde um Felder wie `whale_threshold`, `min_requirement`, `scraper_keywords` und `industry_group` erweitert. diff --git a/company-explorer/backend/config.py b/company-explorer/backend/config.py index 1aed23b4..6d80fe3b 100644 --- a/company-explorer/backend/config.py +++ b/company-explorer/backend/config.py @@ -10,7 +10,7 @@ try: class Settings(BaseSettings): # App Info APP_NAME: str = "Company Explorer" - VERSION: str = "0.4.0" + VERSION: str = "0.6.1" DEBUG: bool = True # Database (Store in App dir for simplicity) diff --git a/company-explorer/backend/database.py b/company-explorer/backend/database.py index de368847..cb70b898 100644 --- a/company-explorer/backend/database.py +++ b/company-explorer/backend/database.py @@ -87,18 +87,20 @@ class Industry(Base): notion_id = Column(String, unique=True, index=True, nullable=True) # Notion Page ID name = Column(String, unique=True, index=True) - description = Column(Text, nullable=True) # Abgrenzung + description = Column(Text, nullable=True) # Definition aus Notion - # Notion Sync Fields - industry_group = Column(String, nullable=True) + # Notion Sync Fields (V3.0+) status_notion = Column(String, nullable=True) # e.g. "P1 Focus Industry" is_focus = Column(Boolean, default=False) # Derived from status_notion - whale_threshold = Column(Float, nullable=True) + # NEW SCHEMA FIELDS (from MIGRATION_PLAN) + metric_type = Column(String, nullable=True) # Unit_Count, Area_in, Area_out min_requirement = Column(Float, nullable=True) - scraper_keywords = Column(Text, nullable=True) - core_unit = Column(String, nullable=True) - proxy_factor = Column(String, nullable=True) + whale_threshold = Column(Float, nullable=True) + proxy_factor = Column(Float, nullable=True) + scraper_search_term = Column(Text, nullable=True) + scraper_keywords = Column(Text, nullable=True) # JSON-Array von Strings + standardization_logic = Column(Text, nullable=True) # Formel, z.B. "wert * 25m²" # Optional link to a Robotics Category (the "product" relevant for this industry) primary_category_id = Column(Integer, ForeignKey("robotics_categories.id"), nullable=True) diff --git a/company-explorer/backend/scripts/sync_notion_industries.py b/company-explorer/backend/scripts/sync_notion_industries.py index 5fdee19c..38b7461e 100644 --- a/company-explorer/backend/scripts/sync_notion_industries.py +++ b/company-explorer/backend/scripts/sync_notion_industries.py @@ -11,7 +11,7 @@ from backend.database import SessionLocal, Industry, RoboticsCategory, init_db from backend.config import settings # Setup Logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) NOTION_TOKEN_FILE = "/app/notion_token.txt" @@ -71,6 +71,9 @@ def extract_number(prop): def sync_categories(token, session): logger.info("Syncing Robotics Categories...") + # session.query(RoboticsCategory).delete() # DANGEROUS - Reverted to Upsert + # session.commit() + pages = query_notion_db(token, CATEGORIES_DB_ID) count = 0 @@ -98,16 +101,19 @@ def sync_categories(token, session): cat.name = name cat.description = description - # cat.reasoning_guide = ... ? Maybe 'Constrains'? cat.reasoning_guide = extract_rich_text(props.get("Constrains")) count += 1 session.commit() - logger.info(f"Synced {count} categories.") + logger.info(f"Synced (Upsert) {count} categories.") def sync_industries(token, session): logger.info("Syncing Industries...") + logger.warning("DELETING all existing industries before sync...") + session.query(Industry).delete() + session.commit() + pages = query_notion_db(token, INDUSTRIES_DB_ID) count = 0 @@ -115,21 +121,17 @@ def sync_industries(token, session): props = page.get("properties", {}) notion_id = page["id"] - name = extract_title(props.get("Industry")) + # In Notion, the column is now 'Vertical' not 'Industry' + name = extract_title(props.get("Vertical")) if not name: continue - # Upsert Logic: Check ID -> Check Name -> Create - industry = session.query(Industry).filter(Industry.notion_id == notion_id).first() - if not industry: - industry = session.query(Industry).filter(Industry.name == name).first() - if industry: - logger.info(f"Linked existing industry '{name}' to Notion ID {notion_id}") - industry.notion_id = notion_id - else: - industry = Industry(notion_id=notion_id, name=name) - session.add(industry) + # Removed full Notion props debug log - no longer needed + + # Logic is now INSERT only + industry = Industry(notion_id=notion_id, name=name) + session.add(industry) - # Map Fields + # Map Fields from Notion Schema industry.name = name industry.description = extract_rich_text(props.get("Definition")) @@ -137,18 +139,19 @@ def sync_industries(token, session): industry.status_notion = status industry.is_focus = (status == "P1 Focus Industry") - industry.industry_group = extract_rich_text(props.get("Industry-Group")) - industry.whale_threshold = extract_number(props.get("Whale Threshold")) + # New Schema Fields + industry.metric_type = extract_select(props.get("Metric Type")) industry.min_requirement = extract_number(props.get("Min. Requirement")) + industry.whale_threshold = extract_number(props.get("Whale Threshold")) + industry.proxy_factor = extract_number(props.get("Proxy Factor")) + industry.scraper_search_term = extract_select(props.get("Scraper Search Term")) # <-- FIXED HERE industry.scraper_keywords = extract_rich_text(props.get("Scraper Keywords")) - industry.core_unit = extract_select(props.get("Core Unit")) - industry.proxy_factor = extract_rich_text(props.get("Proxy Factor")) - + industry.standardization_logic = extract_rich_text(props.get("Stanardization Logic")) + # Relation: Primary Product Category relation = props.get("Primary Product Category", {}).get("relation", []) if relation: related_id = relation[0]["id"] - # Find Category by notion_id cat = session.query(RoboticsCategory).filter(RoboticsCategory.notion_id == related_id).first() if cat: industry.primary_category_id = cat.id @@ -173,4 +176,4 @@ if __name__ == "__main__": except Exception as e: logger.error(f"Sync failed: {e}", exc_info=True) finally: - db.close() + db.close() \ No newline at end of file diff --git a/company-explorer/frontend/src/App.tsx b/company-explorer/frontend/src/App.tsx index 713803b6..442f3de6 100644 --- a/company-explorer/frontend/src/App.tsx +++ b/company-explorer/frontend/src/App.tsx @@ -80,7 +80,7 @@ function App() {

Company Explorer

-

ROBOTICS EDITION v0.5.0

+

ROBOTICS EDITION v0.6.1

diff --git a/company-explorer/frontend/src/components/RoboticsSettings.tsx b/company-explorer/frontend/src/components/RoboticsSettings.tsx index c92391f4..d9cd48a7 100644 --- a/company-explorer/frontend/src/components/RoboticsSettings.tsx +++ b/company-explorer/frontend/src/components/RoboticsSettings.tsx @@ -10,49 +10,81 @@ interface RoboticsSettingsProps { } export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsProps) { - const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles'>('robotics') + const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles'>( + localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' || 'robotics' + ) - // Data States const [roboticsCategories, setRoboticsCategories] = useState([]) const [industries, setIndustries] = useState([]) const [jobRoles, setJobRoles] = useState([]) + const [isLoading, setIsLoading] = useState(false); - const fetchRobotics = async () => { - try { const res = await axios.get(`${apiBase}/robotics/categories`); setRoboticsCategories(res.data) } catch (e) { console.error(e) } - } - - const fetchIndustries = async () => { - try { const res = await axios.get(`${apiBase}/industries`); setIndustries(res.data) } catch (e) { console.error(e) } - } - - const fetchJobRoles = async () => { - try { const res = await axios.get(`${apiBase}/job_roles`); setJobRoles(res.data) } catch (e) { console.error(e) } - } + const fetchAllData = async () => { + setIsLoading(true); + try { + const [resRobotics, resIndustries, resJobRoles] = await Promise.all([ + axios.get(`${apiBase}/robotics/categories`), + axios.get(`${apiBase}/industries`), + axios.get(`${apiBase}/job_roles`), + ]); + setRoboticsCategories(resRobotics.data); + setIndustries(resIndustries.data); + setJobRoles(resJobRoles.data); + } catch (e) { + console.error("Failed to fetch settings data:", e); + alert("Fehler beim Laden der Settings. Siehe Konsole."); + } finally { + setIsLoading(false); + } + }; useEffect(() => { if (isOpen) { - fetchRobotics() - fetchIndustries() - fetchJobRoles() + fetchAllData(); } - }, [isOpen]) + }, [isOpen]); + + useEffect(() => { + localStorage.setItem('roboticsSettingsActiveTab', activeTab); + }, [activeTab]); + - // Robotics Handlers const handleUpdateRobotics = async (id: number, description: string, reasoning: string) => { + setIsLoading(true); try { - await axios.put(`${apiBase}/robotics/categories/${id}`, { description, reasoning_guide: reasoning }) - fetchRobotics() - } catch (e) { alert("Update failed") } + await axios.put(`${apiBase}/robotics/categories/${id}`, { description, reasoning_guide: reasoning }); + fetchAllData(); + } catch (e) { + alert("Update failed"); + console.error(e); + } finally { + setIsLoading(false); + } } - // Industry Handlers Removed (Notion SSoT) - - // Job Role Handlers const handleAddJobRole = async () => { - try { await axios.post(`${apiBase}/job_roles`, { pattern: "New Pattern", role: "Operativer Entscheider" }); fetchJobRoles() } catch (e) { alert("Failed") } + setIsLoading(true); + try { + await axios.post(`${apiBase}/job_roles`, { pattern: "New Pattern", role: "Operativer Entscheider" }); + fetchAllData(); + } catch (e) { + alert("Failed to add job role"); + console.error(e); + } finally { + setIsLoading(false); + } } const handleDeleteJobRole = async (id: number) => { - try { await axios.delete(`${apiBase}/job_roles/${id}`); fetchJobRoles() } catch (e) { alert("Failed") } + setIsLoading(true); + try { + await axios.delete(`${apiBase}/job_roles/${id}`); + fetchAllData(); + } catch (e) { + alert("Failed to delete job role"); + console.error(e); + } finally { + setIsLoading(false); + } } if (!isOpen) return null @@ -96,42 +128,29 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP {/* Content */}
- {/* ROBOTICS TAB */} - {activeTab === 'robotics' && ( -
- {roboticsCategories.map(cat => ( - - ))} + {isLoading &&
Loading...
} + + {!isLoading && activeTab === 'robotics' && ( +
+ {roboticsCategories.map(cat => ( ))}
)} - {/* INDUSTRIES TAB */} - {activeTab === 'industries' && ( -
+ {!isLoading && activeTab === 'industries' && ( +

Industry Verticals (Synced from Notion)

- {/* Notion SSoT: Creation disabled here - - */}
{industries.map(ind => (
- {/* Sync Indicator */} {ind.notion_id && ( -
- SYNCED -
+
SYNCED
)} - - {/* Top Row: Name, Status, Group */}

{ind.name}

- {ind.industry_group && {ind.industry_group}} {ind.status_notion && {ind.status_notion}}
@@ -142,94 +161,36 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
- - {/* Description */}

{ind.description || "No definition"}

- - {/* Metrics Grid */}
-
- Whale > - {ind.whale_threshold || "-"} -
-
- Min Req - {ind.min_requirement || "-"} -
-
- Unit - {ind.core_unit || "-"} -
-
- Product - - {roboticsCategories.find(c => c.id === ind.primary_category_id)?.name || "-"} - -
+
Whale >{ind.whale_threshold || "-"}
+
Min Req{ind.min_requirement || "-"}
+
Unit{ind.scraper_search_term || "-"}
+
Product{roboticsCategories.find(c => c.id === ind.primary_category_id)?.name || "-"}
- - {/* Keywords */} - {ind.scraper_keywords && ( -
- Keywords: - {ind.scraper_keywords} -
- )} + {ind.scraper_keywords &&
Keywords:{ind.scraper_keywords}
} + {ind.standardization_logic &&
Standardization:{ind.standardization_logic}
}
))}
)} - {/* JOB ROLES TAB */} - {activeTab === 'roles' && ( -
-
-

Job Title Mapping Patterns

- -
+ {!isLoading && activeTab === 'roles' && ( +
+

Job Title Mapping Patterns

- - - - - - - + {jobRoles.map(role => ( - - - + + + ))} - {jobRoles.length === 0 && ( - - )} + {jobRoles.length === 0 && ()}
Job Title Pattern (Regex/Text)Mapped Role
Job Title Pattern (Regex/Text)Mapped Role
- - - - - -
No patterns defined yet.
No patterns defined yet.
@@ -246,45 +207,17 @@ function CategoryCard({ category, onSave }: { category: any, onSave: any }) { const [guide, setGuide] = useState(category.reasoning_guide) const [isDirty, setIsDirty] = useState(false) - useEffect(() => { - setIsDirty(desc !== category.description || guide !== category.reasoning_guide) - }, [desc, guide]) + useEffect(() => { setIsDirty(desc !== category.description || guide !== category.reasoning_guide) }, [desc, guide]) return (
-
- -
+
{category.name}
- -
- -