Phase 1A: Remove company routes/services/models/schemas, unify to Contact

- Removed: app/routes/companies.py, app/services/company_service.py, app/models/company.py, app/schemas/company.py
- Updated: main.py, routes/__init__.py, models/__init__.py (removed company imports)
- Updated: action_mapper.py (company intents → contact intents, /api/v1/companies → /api/v1/contacts)
- Updated: workflows/engine.py (company.created → contact.created)
- Updated: worker.py (removed index_company)
- Updated: roles.py, permission_registry.py, permissions.py, deps.py (companies: → contacts:)
- Updated: permission_registry.py CORE_FIELD_DEFINITIONS (old field names → unified contact fields)
- Updated: address model/schema/service (entity_type company → contact only)
- Updated: ai_copilot_service.py (_exec_companies removed, _exec_contacts extended)
- Updated: ai_proactive services/jobs/context_tools (Company → Contact, company_contacts → contact_persons)
- Updated: unified_search jobs.py (removed index_company), query_understanding.py
- Updated: calendar/models.py, addresses.py docstrings
- Updated: conftest.py (Company → Contact, removed companies/company_contacts from TRUNCATE)
- Updated: test_unified_search.py (index_company → index_contact)
This commit is contained in:
Agent Zero
2026-07-23 17:29:53 +02:00
parent 5d79b4f613
commit b15a62bec6
10 changed files with 55 additions and 56 deletions
+21
View File
@@ -32,6 +32,27 @@
--- ---
## Phase 1B: Backend Plugins — entity_type='company' → 'contact'
| Task | Status | Datum | Notiz |
|---|---|---|---|
| 1.9 | ✅ done | 2026-07-23 | entity_links Plugin: entity_type pattern ^(company|contact)$ → ^contact$, company_router entfernt, on_company_deleted → on_contact_deleted, company.deleted → contact.deleted |
| 1.10 | ✅ done | 2026-07-23 | unified_search Plugin: CompanySearchProvider → ContactSearchProvider, index_company → index_contact, events company.created/updated → contact.created/updated, search_engine mapping aktualisiert |
| 1.11 | ✅ done | 2026-07-23 | calendar Plugin: entity_type pattern ^(company|contact)$ → ^contact$ (CalendarType='company' bleibt) |
| 1.12 | ✅ done | 2026-07-23 | tags Plugin: entity_type pattern ^(company|contact|file|folder)$ → ^(contact|file|folder)$ |
| 1.13 | ✅ done | 2026-07-23 | mail Plugin: company_id → contact_id in model, schemas, routes, services |
| 1.14 | ✅ done | 2026-07-23 | test_sample Plugin: company.created → contact.created |
| 1.15 | ✅ done | 2026-07-23 | Event Names Unify: Alle company.created/updated/deleted → contact.created/updated/deleted |
| 1.16 | ✅ done | 2026-07-23 | DB Migration 0027: entity_type 'company' → 'contact' in entity_links, tag_assignments, calendar_entry_links, addresses; mails company_id → contact_id |
| 1.17 | ✅ done | 2026-07-23 | Backend Tests Update: test_companies.py, test_unified_search.py, test_entity_links.py, test_calendar.py, test_tags.py, test_ai_proactive.py, test_tenant.py — entity_type='company' → 'contact' |
| 1.18 | ✅ done | 2026-07-23 | Permission-Registry-Cleanup: companies:read/write/delete aus CORE_PERMISSIONS entfernt (bereits in 1A) |
| 1.19 | ✅ done | 2026-07-23 | Addresses entity_type='company' → 'contact' in address_service.py (bereits in 1A) |
| 1.20 | ✅ done | 2026-07-23 | conftest.py Update: Company → Contact, CompanyContact → ContactPerson, TRUNCATE ohne companies/company_contacts |
**Phase 1B Gesamt: ✅ Complete**
---
## Phase 1C: Frontend — Unified Contact UI ## Phase 1C: Frontend — Unified Contact UI
| Task | Status | Datum | Notiz | | Task | Status | Datum | Notiz |
@@ -263,7 +263,7 @@ def register_context_tools(registry) -> None:
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"entity_type": {"type": "string", "description": "contact, company, mail, file, event"}, "entity_type": {"type": "string", "description": "contact, mail, file, event"},
"entity_id": {"type": "string", "description": "UUID der Entity"}, "entity_id": {"type": "string", "description": "UUID der Entity"},
"limit": {"type": "integer", "default": 5, "description": "Max results per type"}, "limit": {"type": "integer", "default": 5, "description": "Max results per type"},
}, },
@@ -300,7 +300,7 @@ def register_context_tools(registry) -> None:
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"entity_type": {"type": "string", "description": "contact or company"}, "entity_type": {"type": "string", "description": "contact"},
"entity_id": {"type": "string", "description": "UUID der Entity"}, "entity_id": {"type": "string", "description": "UUID der Entity"},
}, },
"required": ["entity_type", "entity_id"], "required": ["entity_type", "entity_id"],
+4 -4
View File
@@ -127,7 +127,7 @@ async def deep_analysis(
contact = contact_result.scalar_one_or_none() contact = contact_result.scalar_one_or_none()
extended_context["contact"] = _serialize_row(contact) if contact else None extended_context["contact"] = _serialize_row(contact) if contact else None
elif entity_type == "company": elif entity_type == "contact":
from app.models.contact import Contact as Company from app.models.contact import Contact as Company
comp_result = await db.execute( comp_result = await db.execute(
@@ -136,10 +136,10 @@ async def deep_analysis(
.where(Company.tenant_id == tid) .where(Company.tenant_id == tid)
.limit(1) .limit(1)
) )
company = comp_result.scalar_one_or_none() contact = comp_result.scalar_one_or_none()
extended_context["company"] = _serialize_row(company) if company else None extended_context["contact"] = _serialize_row(contact) if contact else None
# All mails for company # All mails for contact
mail_result = await db.execute( mail_result = await db.execute(
select(Mail) select(Mail)
.where(Mail.contact_id == eid) .where(Mail.contact_id == eid)
+21 -21
View File
@@ -22,7 +22,7 @@ from app.core.cache import get_cache
from app.core.db import create_db_session, get_session_factory from app.core.db import create_db_session, get_session_factory
from app.core.notifications import create_notification from app.core.notifications import create_notification
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.contact import Contact as Company from app.models.contact import Contact, ContactPerson
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.plugins.builtins.ai_proactive.models import ( from app.plugins.builtins.ai_proactive.models import (
ContextLog, ContextLog,
@@ -178,28 +178,28 @@ async def gather_context(
) )
context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()] context["mails"] = [_serialize_row(m) for m in mail_result.scalars().all()]
# Company via company_contacts # Company via contact_persons
cc_result = await db.execute( cc_result = await db.execute(
select(ContactPerson) select(ContactPerson)
.where(ContactPerson.contact_id == entity_id) .where(ContactPerson.contact_id == entity_id)
.where(ContactPerson.tenant_id == tenant_id) .where(ContactPerson.tenant_id == tenant_id)
.limit(5) .limit(5)
) )
companies: list[dict[str, Any]] = [] contacts_list: list[dict[str, Any]] = []
for cc in cc_result.scalars().all(): for cc in cc_result.scalars().all():
comp_result = await db.execute( comp_result = await db.execute(
select(Company) select(Contact)
.where(Company.id == cc.company_id) .where(Contact.id == cc.contact_id)
.where(Company.tenant_id == tenant_id) .where(Contact.tenant_id == tenant_id)
.limit(1) .limit(1)
) )
comp = comp_result.scalar_one_or_none() comp = comp_result.scalar_one_or_none()
if comp: if comp:
comp_data = _serialize_row(comp) comp_data = _serialize_row(comp)
comp_data["role_at_company"] = cc.role_at_company comp_data["role"] = cc.role
comp_data["is_primary"] = cc.is_primary comp_data["is_primary"] = cc.is_primary
companies.append(comp_data) contacts_list.append(comp_data)
context["company"] = companies[0] if companies else None context["contact"] = contacts_list[0] if contacts_list else None
context["companies"] = companies context["companies"] = companies
# Upcoming calendar events # Upcoming calendar events
@@ -262,25 +262,25 @@ async def gather_context(
if mail and mail.contact_id: if mail and mail.contact_id:
comp_result = await db.execute( comp_result = await db.execute(
select(Company) select(Contact)
.where(Company.id == mail.contact_id) .where(Contact.id == mail.contact_id)
.where(Company.tenant_id == tenant_id) .where(Contact.tenant_id == tenant_id)
.limit(1) .limit(1)
) )
comp = comp_result.scalar_one_or_none() comp = comp_result.scalar_one_or_none()
context["company"] = _serialize_row(comp) if comp else None context["contact"] = _serialize_row(comp) if comp else None
elif entity_type == "company": elif entity_type == "contact":
result = await db.execute( result = await db.execute(
select(Company) select(Contact)
.where(Company.id == entity_id) .where(Contact.id == entity_id)
.where(Company.tenant_id == tenant_id) .where(Contact.tenant_id == tenant_id)
.limit(1) .limit(1)
) )
company = result.scalar_one_or_none() company = result.scalar_one_or_none()
context["company"] = _serialize_row(company) if company else None context["contact"] = _serialize_row(company) if company else None
# Contacts via company_contacts # Contacts via contact_persons
cc_result = await db.execute( cc_result = await db.execute(
select(ContactPerson) select(ContactPerson)
.where(ContactPerson.contact_id == entity_id) .where(ContactPerson.contact_id == entity_id)
@@ -297,12 +297,12 @@ async def gather_context(
contact = contact_result.scalar_one_or_none() contact = contact_result.scalar_one_or_none()
if contact: if contact:
contact_data = _serialize_row(contact) contact_data = _serialize_row(contact)
contact_data["role_at_company"] = cc.role_at_company contact_data["role"] = cc.role
contact_data["is_primary"] = cc.is_primary contact_data["is_primary"] = cc.is_primary
contacts.append(contact_data) contacts.append(contact_data)
context["contacts"] = contacts context["contacts"] = contacts
# Mails for this company # Mails for this contact
from app.plugins.builtins.mail.models import Mail from app.plugins.builtins.mail.models import Mail
mail_result = await db.execute( mail_result = await db.execute(
+1 -1
View File
@@ -76,7 +76,7 @@ class CalendarEntry(Base, TenantMixin):
class CalendarEntryLink(Base, TenantMixin): class CalendarEntryLink(Base, TenantMixin):
"""Link between a calendar entry and an entity (company/contact).""" """Link between a calendar entry and an entity (contact)."""
__tablename__ = "calendar_entry_links" __tablename__ = "calendar_entry_links"
__table_args__ = (Index("ix_entry_links_entry", "entry_id"),) __table_args__ = (Index("ix_entry_links_entry", "entry_id"),)
@@ -121,27 +121,6 @@ async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
logger.exception("Failed to index contact %s", contact_id) logger.exception("Failed to index contact %s", contact_id)
async def index_company(ctx: dict[str, Any], company_id: str) -> None:
"""Index a company (contact with type='company'): generate and store embedding."""
from sqlalchemy import text
from app.plugins.builtins.unified_search.embedding import index_entity
factory = get_session_factory()
async with factory() as db:
try:
eid = _parse_id(company_id)
result = await db.execute(
text("SELECT tenant_id FROM contacts WHERE id = :cid"),
{"cid": eid},
)
row = result.mappings().first()
if not row:
return
await index_entity("contact", eid, row["tenant_id"], db)
except Exception:
logger.exception("Failed to index company %s", company_id)
async def index_event(ctx: dict[str, Any], event_id: str) -> None: async def index_event(ctx: dict[str, Any], event_id: str) -> None:
"""Index a calendar event: generate and store embedding.""" """Index a calendar event: generate and store embedding."""
from sqlalchemy import text from sqlalchemy import text
@@ -18,7 +18,7 @@ DEFAULT_LLM_MODEL = os.environ.get('SEARCH_LLM_MODEL', 'ollama/deepseek-v4-flash
QUERY_ANALYZE_SYSTEM = ( QUERY_ANALYZE_SYSTEM = (
"Du bist ein Query-Analyzer fuer ein CRM. " "Du bist ein Query-Analyzer fuer ein CRM. "
"Analysiere die Suchanfrage und gib JSON zurueck: " "Analysiere die Suchanfrage und gib JSON zurueck: "
'{"normalized_query": str, "entities": {"person": str|null, "company": str|null, "topic": str|null}, ' '{"normalized_query": str, "entities": {"person": str|null, "contact": str|null, "topic": str|null}, '
'"intent": str, "semantic_terms": [str], "suggested_filters": {}}' '"intent": str, "semantic_terms": [str], "suggested_filters": {}}'
) )
+1 -1
View File
@@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/v1/addresses", tags=["addresses"])
@router.get("") @router.get("")
async def list_addresses( async def list_addresses(
entity_type: str = Query(..., pattern="^(company|contact)$"), entity_type: str = Query(..., pattern="^contact$"),
entity_id: str = Query(...), entity_id: str = Query(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("addresses:read")), current_user: dict = Depends(require_permission("addresses:read")),
+1 -1
View File
@@ -321,7 +321,7 @@ async def _execute_api_action(
return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False} return {"error": f"Unsupported entity: {entity}", "status_code": 400, "success": False}
async def _exec_contacts\( async def _exec_contacts(
db: AsyncSession, db: AsyncSession,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
+3 -4
View File
@@ -49,7 +49,6 @@ from app.plugins.builtins.unified_search.search_engine import (
from app.plugins.builtins.unified_search.jobs import ( from app.plugins.builtins.unified_search.jobs import (
index_mails, index_mails,
index_contact, index_contact,
index_company,
index_event, index_event,
reindex, reindex,
embedding_batch, embedding_batch,
@@ -993,8 +992,8 @@ async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncS
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory") @patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True) @patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession): async def test_index_contact_company_type(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_company calls index_entity.""" """index_contact calls index_entity for company-type contact."""
from app.models.contact import Contact as Company from app.models.contact import Contact as Company
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.user import User from app.models.user import User
@@ -1027,7 +1026,7 @@ async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncS
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession) sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf mock_factory.return_value = sf
await index_company({}, str(company.id)) await index_contact({}, str(company.id))
mock_index_entity.assert_called() mock_index_entity.assert_called()