Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,6 @@
## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions
**Learning:** Hardcoded ARIA labels in mockups (like "μΆœμ‹œ 회의 일정 μ‚­μ œ") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states.
**Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} μ‚­μ œ`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet.
## 2024-07-28 - Explicit Loading Feedback for OIDC Auth Buttons
**Learning:** OIDC login and logout buttons often trigger asynchronous operations (like redirects or session clearing) but may lack explicit loading states. `disabled` alone is insufficient feedback for auth actions which can take time.
**Action:** When adding or auditing asynchronous auth check actions, always combine `disabled={isLoading}`, `aria-busy={isLoading}`, a `Loader2` spinner, and a dynamic text label (e.g. `isLoading ? '둜그인 쀑' : 'OIDC 둜그인'`) to give immediate, unambiguous feedback.
1 change: 0 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
## [Unreleased]
- κΈ΄ 이메일·첨뢀 본문을 의미 λ‹¨μœ„ 청크둜 μž„λ² λ”©ν•œ λ’€ κΈ°μ‘΄ email/attachment 벑터 κ³„μ•½μœΌλ‘œ ν‰κ· ν™”ν•˜κ³ , 청크 μš”μ²­Β·λ²‘ν„° λˆ„μ μ„ μ œν•œλœ 창으둜 μ²˜λ¦¬ν•©λ‹ˆλ‹€. OpenAI `text-embedding-3-*`μ—λŠ” μ €μž₯ 차원(`1536`)을 직접 μš”μ²­ν•˜λ„λ‘ λ³΄κ°•ν–ˆμŠ΅λ‹ˆλ‹€. ν•©μ„± 메일 fixture 5건(70청크)κ³Ό provider μš”μ²­ κ³„μ•½μœΌλ‘œ 1,536차원 벑터 경둜λ₯Ό κ²€μ¦ν–ˆμœΌλ©°, μ‹€ν–‰ μ‹œ μ„ νƒν•œ μž„λ² λ”© μ œκ³΅μžμ— λ³Έλ¬ΈΒ·νŒŒμ‹±λœ 첨뢀 ν…μŠ€νŠΈλ₯Ό 전솑할 수 μžˆμŠ΅λ‹ˆλ‹€. νšŒμ‚¬ κΈ°λ°€ λ°μ΄ν„°λŠ” fixtureΒ·commitΒ·PRΒ·log에 ν¬ν•¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
- EmailDetail ν…ŒμŠ€νŠΈκ°€ μ§€μ›ν•˜μ§€ μ•ŠλŠ” μŠ€λ ˆλ“œ 병합/뢄리 λ²„νŠΌμ„ `textContent`뿐 μ•„λ‹ˆλΌ `aria-label`κ³Ό `title` μ ‘κ·Ό κ°€λŠ₯ μ΄λ¦„μœΌλ‘œλ„ κ²€μΆœν•˜λ„λ‘ λ°”κΏ”, μ•„μ΄μ½˜ μ „μš© λ²„νŠΌ νšŒκ·€λ₯Ό λ†“μΉ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

### μΊ˜λ¦°λ” 좩돌 (Status-weighted conflicts)
Expand Down
148 changes: 11 additions & 137 deletions backend/services/batch_embedding_service.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ” OIDC PR also reverts a merged backend embedding feature

The single commit titled 'Add loading state to OIDC buttons' also reverts the merged #1413 'chunk oversized embedding inputs' feature: batch_embedding_service.py loses byte/count partitioning and BatchEmbeddingPartial, email_import_service.py loses chunking/averaging and drops MAX_IMPORT_UPLOAD_BYTES from 64 MiB back to 20 MiB, embedding.py loses the native-dimensions request, and the covering tests are deleted. The description and CHANGELOG diff mention only OIDC. This looks like an accidental revert bundled into an unrelated UI change; confirm with the maintainer before merging.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
from __future__ import annotations

import asyncio
import json
import logging
import uuid
from dataclasses import dataclass
Expand Down Expand Up @@ -62,11 +61,6 @@
# Poll budget while the orchestrator drains the batch through pg-llm-batch.
_ORCHESTRATOR_POLL_INTERVAL_SECONDS = 1.0
_ORCHESTRATOR_MAX_POLLS = 30
# Keep each JSON request below the orchestrator's body budget with envelope
# headroom. Import chunks are normally <= 1,000 characters, but the byte check
# also protects direct callers that provide longer or multibyte inputs.
_ORCHESTRATOR_MAX_INPUTS_PER_REQUEST = 32
_ORCHESTRATOR_MAX_INPUT_BYTES = 48 * 1024
_SUCCESS_STATUSES = frozenset({"completed", "succeeded"})

# Cache the (im)port result so repeated imports don't re-probe sys.path.
Expand Down Expand Up @@ -120,14 +114,6 @@ def has_local_fallback(self) -> bool:
return bool(self.local_dsn)


@dataclass(frozen=True)
class BatchEmbeddingPartial:
"""Completed prefix plus inputs that still need the normal fallback path."""

completed_vectors: list[list[float]]
pending_texts: list[str]


async def resolve_batch_embedding_settings(
session: AsyncSession,
*,
Expand Down Expand Up @@ -159,7 +145,9 @@ async def resolve_batch_embedding_settings(
attribution_service=_clean(
getattr(tenant_config, "batch_attribution_service", None)
),
attribution_team=_clean(getattr(tenant_config, "batch_attribution_team", None)),
attribution_team=_clean(
getattr(tenant_config, "batch_attribution_team", None)
),
attribution_group=_clean(
getattr(tenant_config, "batch_attribution_group", None)
),
Expand Down Expand Up @@ -188,16 +176,15 @@ async def try_batch_import_embeddings(
user_id: str,
organization_id: str | None,
dimension: int = STORAGE_EMBEDDING_DIMENSION,
) -> list[list[float]] | BatchEmbeddingPartial | None:
) -> list[list[float]] | None:
"""Route bulk embeddings through the batch path, or ``None`` to fall back.

On success returns one fitted vector per input text (original order). The
primary path submits to contextual-orchestrator; only if the orchestrator is
unconfigured or unavailable does it consider the local ``pg-llm-batch``
package fallback. A later partition failure returns a completed prefix plus
only the unfinished inputs so the caller does not resend successful work.
The run is recorded in ``llm_batch_jobs`` / ``llm_batch_items`` for
observability.
package fallback. Any failure returns ``None`` so the caller uses its
per-item path. The run is recorded in ``llm_batch_jobs`` / ``llm_batch_items``
for observability.
"""
if not texts:
return None
Expand All @@ -211,7 +198,7 @@ async def try_batch_import_embeddings(
model = settings.model or embedding_provider.embedding_model

if settings.has_orchestrator:
result = await _run_orchestrator_batches(
result = await _run_orchestrator_batch(
session,
texts,
settings=settings,
Comment on lines 200 to 204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Info: Orchestrator batch no longer bounds request size

_run_orchestrator_batch now posts all input texts in one request; the removed partitioning previously split by count (32) and a 48 KiB JSON byte budget. A large import can exceed the orchestrator body budget, though the failure degrades gracefully to the per-item path rather than crashing.

(Refers to this code)

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Expand Down Expand Up @@ -242,121 +229,6 @@ async def try_batch_import_embeddings(
# --- Primary path: contextual-orchestrator batch API ------------------------


def _serialized_orchestrator_payload_bytes(
inputs: list[str],
*,
model: str,
endpoint_alias: str | None,
metadata: dict[str, str],
) -> int:
"""Return the UTF-8 size of the request envelope sent to the orchestrator."""
payload = {
"model": model,
"endpoint": endpoint_alias,
"inputs": inputs,
"metadata": metadata,
}
return len(
json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
)


def _partition_orchestrator_inputs(
texts: list[str],
*,
model: str = "",
endpoint_alias: str | None = None,
metadata: dict[str, str] | None = None,
) -> list[list[str]] | None:
"""Partition inputs by count and serialized JSON request bytes."""
request_metadata = metadata or {}
partitions: list[list[str]] = []
current: list[str] = []
for text in texts:
candidate = [*current, text]
if current and (
len(candidate) > _ORCHESTRATOR_MAX_INPUTS_PER_REQUEST
or _serialized_orchestrator_payload_bytes(
candidate,
model=model,
endpoint_alias=endpoint_alias,
metadata=request_metadata,
)
> _ORCHESTRATOR_MAX_INPUT_BYTES
):
partitions.append(current)
candidate = [text]
if (
_serialized_orchestrator_payload_bytes(
candidate,
model=model,
endpoint_alias=endpoint_alias,
metadata=request_metadata,
)
> _ORCHESTRATOR_MAX_INPUT_BYTES
):
return None
current = candidate
if current:
partitions.append(current)
return partitions


async def _run_orchestrator_batches(
session: AsyncSession,
texts: list[str],
*,
settings: BatchEmbeddingSettings,
model: str,
user_id: str,
organization_id: str | None,
dimension: int,
) -> list[list[float]] | BatchEmbeddingPartial | None:
"""Submit bounded requests and concatenate vectors in original order."""
metadata = _attribution_metadata(
settings=settings,
user_id=user_id,
organization_id=organization_id,
)
partitions = _partition_orchestrator_inputs(
texts,
model=model,
endpoint_alias=settings.endpoint_alias,
metadata=metadata,
)
if partitions is None:
logger.warning(
"Orchestrator batch input exceeded one-request byte budget; falling back: "
"text_count=%s",
len(texts),
)
return None

vectors: list[list[float]] = []
for partition_index, partition in enumerate(partitions):
partition_vectors = await _run_orchestrator_batch(
session,
partition,
settings=settings,
model=model,
user_id=user_id,
organization_id=organization_id,
dimension=dimension,
)
if partition_vectors is None:
if not vectors:
return None
pending_texts = [
text for remaining in partitions[partition_index:] for text in remaining
]
return BatchEmbeddingPartial(
completed_vectors=vectors,
pending_texts=pending_texts,
)
vectors.extend(partition_vectors)
return vectors


async def _run_orchestrator_batch(
session: AsyncSession,
texts: list[str],
Expand Down Expand Up @@ -500,7 +372,9 @@ async def _submit_and_await(
if status in _SUCCESS_STATUSES and document.get("embeddings") is not None:
return document
if status in ("failed", "error", "canceled"):
raise EmbeddingGenerationError(f"orchestrator batch rejected: status={status}")
raise EmbeddingGenerationError(
f"orchestrator batch rejected: status={status}"
)

batch_id = document.get("batch_id") or document.get("id")
if not batch_id:
Expand Down
100 changes: 18 additions & 82 deletions backend/services/email_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,12 @@
KnowledgeGraphEdgeRecord,
)
from services.archive import extract_backup_async
from services.batch_embedding_service import (
BatchEmbeddingPartial,
try_batch_import_embeddings,
)
from services.batch_embedding_service import try_batch_import_embeddings
from services.content_graph import ParseResult, parse_content
from services.email_dedupe_service import strong_email_fingerprint
from services.email_parser import EmailData, parse_eml_bytes
from services.embedding import (
STORAGE_EMBEDDING_DIMENSION,
chunk_text,
fit_embedding_vector,
generate_embeddings,
)
Expand All @@ -56,14 +52,10 @@

EMBEDDING_DIMENSION = STORAGE_EMBEDDING_DIMENSION
MAX_IMPORT_UPLOADS = 10
# Transport safety ceiling only; parser and embedding chunking must accept
# sources larger than 20 MiB without confusing the request guard for a parser
# limit.
MAX_IMPORT_UPLOAD_BYTES = 64 * 1024 * 1024
MAX_IMPORT_UPLOAD_BYTES = 20 * 1024 * 1024

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Info: Upload size ceiling reverted from 64 MiB to 20 MiB

MAX_IMPORT_UPLOAD_BYTES drops from 64 MiB back to 20 MiB and its guarding test is removed, so uploads between 20 and 64 MiB that previously passed are now rejected. This is part of the same backend revert bundled into this UI PR.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

MAX_IMPORT_EML_FILES = 100
MAX_IMPORT_EMAILS_PER_OWNER = 1000
MAX_UPLOAD_FILENAME_DECODE_ROUNDS = 8
MAX_EMBEDDING_CHUNKS_PER_WINDOW = 32
SUPPORTED_EMAIL_IMPORT_SUFFIXES = frozenset({".eml", ".mbox", ".zip"})
EMAIL_IMPORT_QUOTA_LOCK_NAMESPACE = "naruon-email-import-quota"
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -296,52 +288,15 @@ async def _extract_and_generate_embeddings(
batch_context: "EmailImportBatchContext | None" = None,
) -> tuple[list[dict], list[list[float]]]:
attachment_payloads = list(parsed.get("attachments", []))
body_parse_content = parsed.get("body_parse_content")
source_texts = [
str(
body_parse_content
if body_parse_content is not None
else parsed.get("body") or ""
)
]
source_texts.extend(
str(
""
if (attachment.get("parse_status") or "parsed") != "parsed"
else (
attachment.get("parse_content")
if attachment.get("parse_content") is not None
else attachment.get("content") or ""
)
)
for attachment in attachment_payloads
embedding_texts = [str(parsed.get("body") or "")]
embedding_texts.extend(
str(attachment.get("content") or "") for attachment in attachment_payloads
)
fitted_embeddings = await _generate_import_embeddings(
embedding_texts,
embedding_provider=embedding_provider,
batch_context=batch_context,
)
Comment on lines +291 to 299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ Oversized email bodies no longer chunked before embedding

The rewritten _extract_and_generate_embeddings sends each email body and attachment to the embedding provider as one whole string, dropping the previous chunk-and-average step (chunk_text and MAX_EMBEDDING_CHUNKS_PER_WINDOW are removed). A body that exceeds the provider's token limit makes the request fail, the per-item retry in _generate_import_embeddings also fails, and the email is stored with a zero vector, so long emails become unsearchable. This undoes the oversized-input fix already merged on the base branch.

Prompt for agents
This PR reverts the oversized-embedding-input fix (#1413) that exists on the base branch. The rewritten _extract_and_generate_embeddings now embeds each body/attachment whole instead of chunking long text with chunk_text and averaging vectors over bounded windows (MAX_EMBEDDING_CHUNKS_PER_WINDOW). It also drops preference for parsed body content (body_parse_content) and the deferred-attachment (parse_status != 'parsed') skip. Restore the base-branch implementation of _extract_and_generate_embeddings, re-add the chunk_text import from services.embedding and the MAX_EMBEDDING_CHUNKS_PER_WINDOW constant, and confirm this backend revert was not intended by the OIDC-button PR. Verify with backend/tests/test_email_import_service.py.
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

fitted_embeddings: list[list[float]] = []
for source_text in source_texts:
source_chunks = chunk_text(source_text)
if not source_chunks:
fitted_embeddings.append(_zero_embedding())
continue

vector_sum: list[float] | None = None
vector_count = 0
for start in range(0, len(source_chunks), MAX_EMBEDDING_CHUNKS_PER_WINDOW):
chunk_embeddings = await _generate_import_embeddings(
source_chunks[start : start + MAX_EMBEDDING_CHUNKS_PER_WINDOW],
embedding_provider=embedding_provider,
batch_context=batch_context,
)
for embedding in chunk_embeddings:
if vector_sum is None:
vector_sum = [0.0] * len(embedding)
for index, value in enumerate(embedding):
vector_sum[index] += value
vector_count += 1
fitted_embeddings.append(
[value / vector_count for value in vector_sum]
if vector_sum and vector_count
else _zero_embedding()
)
return attachment_payloads, fitted_embeddings


Expand Down Expand Up @@ -439,11 +394,7 @@ def _fallback_attachment_parser_key(
return "calendar"
if parse_content_type == "text/html":
return "html"
if parse_content_type in {
"text/markdown",
"text/x-markdown",
"application/markdown",
}:
if parse_content_type in {"text/markdown", "text/x-markdown", "application/markdown"}:
return "markdown"
if parse_content_type == "text/plain":
return "plain_text"
Expand Down Expand Up @@ -635,9 +586,9 @@ def add_edge(
item.segment_path,
),
):
segments_by_source[(segment.source_kind, segment.source_record_uid)].append(
segment
)
segments_by_source[
(segment.source_kind, segment.source_record_uid)
].append(segment)
add_edge(
edge_kind="node_has_segment",
edge_path=f"{segment.content_node.node_path}/has/{segment.segment_path}",
Expand All @@ -652,7 +603,8 @@ def add_edge(
add_edge(
edge_kind="segment_next",
edge_path=(
f"{source_segment.segment_path}/next/{target_segment.segment_path}"
f"{source_segment.segment_path}/next/"
f"{target_segment.segment_path}"
),
source_kind=source_segment.source_kind,
source_record_uid=source_segment.source_record_uid,
Expand All @@ -676,7 +628,8 @@ def add_edge(
add_edge(
edge_kind="heading_contains_segment",
edge_path=(
f"{heading_segment.segment_path}/contains/{segment.segment_path}"
f"{heading_segment.segment_path}/contains/"
f"{segment.segment_path}"
),
source_kind=segment.source_kind,
source_record_uid=segment.source_record_uid,
Expand Down Expand Up @@ -952,8 +905,6 @@ async def _generate_import_embeddings(
embedding_provider: EmailImportEmbeddingProvider | None,
batch_context: "EmailImportBatchContext | None" = None,
) -> list[list[float]]:
if not texts:
return []
if embedding_provider is None:
return [_zero_embedding() for _ in texts]
if batch_context is not None and texts:
Expand All @@ -970,21 +921,6 @@ async def _generate_import_embeddings(
dimension=EMBEDDING_DIMENSION,
)
if batched is not None:
if isinstance(batched, BatchEmbeddingPartial):
remainder: list[list[float]] = []
for start in range(
0, len(batched.pending_texts), MAX_EMBEDDING_CHUNKS_PER_WINDOW
):
remainder.extend(
await _generate_import_embeddings(
batched.pending_texts[
start : start + MAX_EMBEDDING_CHUNKS_PER_WINDOW
],
embedding_provider=embedding_provider,
batch_context=None,
)
)
return [*batched.completed_vectors, *remainder]
return batched
try:
provider_embeddings = await generate_embeddings(
Expand Down
Loading
Loading