compair-core 0.3.14__py3-none-any.whl → 0.3.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of compair-core might be problematic. Click here for more details.

@@ -1,12 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from typing import Any, Iterable, List
5
+
4
6
  import requests
5
- from typing import Any
6
7
 
7
8
  from .logger import log_event
8
9
  from .models import Document, User
9
10
 
11
+ try:
12
+ import openai # type: ignore
13
+ except ImportError: # pragma: no cover - optional dependency
14
+ openai = None # type: ignore
15
+
10
16
  try:
11
17
  from compair_cloud.feedback import Reviewer as CloudReviewer # type: ignore
12
18
  from compair_cloud.feedback import get_feedback as cloud_get_feedback # type: ignore
@@ -16,64 +22,183 @@ except (ImportError, ModuleNotFoundError):
16
22
 
17
23
 
18
24
  class Reviewer:
19
- """Edition-aware wrapper that falls back to the local feedback endpoint."""
25
+ """Edition-aware wrapper that selects a feedback provider based on configuration."""
20
26
 
21
27
  def __init__(self) -> None:
22
28
  self.edition = os.getenv("COMPAIR_EDITION", "core").lower()
29
+ self.provider = os.getenv("COMPAIR_GENERATION_PROVIDER", "local").lower()
30
+ self.length_map = {
31
+ "Brief": "1–2 short sentences",
32
+ "Detailed": "A couple short paragraphs",
33
+ "Verbose": "As thorough as reasonably possible without repeating information",
34
+ }
35
+
23
36
  self._cloud_impl = None
37
+ self._openai_client = None
38
+ self.openai_model = os.getenv("COMPAIR_OPENAI_MODEL", "gpt-4o-mini")
39
+
24
40
  if self.edition == "cloud" and CloudReviewer is not None:
25
41
  self._cloud_impl = CloudReviewer()
42
+ self.provider = "cloud"
26
43
  else:
27
- self.client = None
28
- self.model = os.getenv("COMPAIR_LOCAL_GENERATION_MODEL", "local-feedback")
29
- base_url = os.getenv("COMPAIR_LOCAL_MODEL_URL", "http://local-model:9000")
30
- route = os.getenv("COMPAIR_LOCAL_GENERATION_ROUTE", "/generate")
31
- self.endpoint = f"{base_url.rstrip('/')}{route}"
44
+ if self.provider == "openai":
45
+ api_key = os.getenv("COMPAIR_OPENAI_API_KEY")
46
+ if api_key and openai is not None:
47
+ # Support both legacy (ChatCompletion) and new SDKs
48
+ if hasattr(openai, "api_key"):
49
+ openai.api_key = api_key # type: ignore[assignment]
50
+ if hasattr(openai, "OpenAI"):
51
+ try: # pragma: no cover - optional runtime dependency
52
+ self._openai_client = openai.OpenAI(api_key=api_key) # type: ignore[attr-defined]
53
+ except Exception: # pragma: no cover - if instantiation fails
54
+ self._openai_client = None
55
+ if self._openai_client is None and not hasattr(openai, "ChatCompletion"):
56
+ log_event("openai_feedback_unavailable", reason="openai_library_missing")
57
+ self.provider = "fallback"
58
+ if self.provider == "local":
59
+ self.model = os.getenv("COMPAIR_LOCAL_GENERATION_MODEL", "local-feedback")
60
+ base_url = os.getenv("COMPAIR_LOCAL_MODEL_URL", "http://local-model:9000")
61
+ route = os.getenv("COMPAIR_LOCAL_GENERATION_ROUTE", "/generate")
62
+ self.endpoint = f"{base_url.rstrip('/')}{route}"
63
+ else:
64
+ self.model = "external"
65
+ self.endpoint = None
32
66
 
33
67
  @property
34
68
  def is_cloud(self) -> bool:
35
69
  return self._cloud_impl is not None
36
70
 
37
71
 
72
+ def _reference_snippets(references: Iterable[Any], limit: int = 3) -> List[str]:
73
+ snippets: List[str] = []
74
+ for ref in references:
75
+ snippet = getattr(ref, "content", "") or ""
76
+ snippet = snippet.replace("\n", " ").strip()
77
+ if snippet:
78
+ snippets.append(snippet[:200])
79
+ if len(snippets) == limit:
80
+ break
81
+ return snippets
82
+
83
+
38
84
  def _fallback_feedback(text: str, references: list[Any]) -> str:
39
- if not references:
40
- return "NONE"
41
- top_ref = references[0]
42
- snippet = getattr(top_ref, "content", "") or ""
43
- snippet = snippet.replace("\n", " ").strip()[:200]
44
- if not snippet:
85
+ snippets = _reference_snippets(references)
86
+ if not snippets:
45
87
  return "NONE"
46
- return f"Check alignment with this reference: {snippet}"
88
+ joined = "; ".join(snippets)
89
+ return f"Consider aligning with these reference passages: {joined}"
47
90
 
48
91
 
49
- def get_feedback(
92
+ def _openai_feedback(
50
93
  reviewer: Reviewer,
51
94
  doc: Document,
52
95
  text: str,
53
96
  references: list[Any],
54
97
  user: User,
55
- ) -> str:
56
- if reviewer.is_cloud and cloud_get_feedback is not None:
57
- return cloud_get_feedback(reviewer._cloud_impl, doc, text, references, user) # type: ignore[arg-type]
98
+ ) -> str | None:
99
+ if openai is None:
100
+ return None
101
+ instruction = reviewer.length_map.get(user.preferred_feedback_length, "1–2 short sentences")
102
+ ref_text = "\n\n".join(_reference_snippets(references, limit=3))
103
+ messages = [
104
+ {
105
+ "role": "system",
106
+ "content": (
107
+ "You are Compair, an assistant that delivers concise, actionable feedback on a user's document. "
108
+ "Focus on clarity, cohesion, and usefulness."
109
+ ),
110
+ },
111
+ {
112
+ "role": "user",
113
+ "content": (
114
+ f"Document:\n{text}\n\nHelpful reference excerpts:\n{ref_text or 'None provided'}\n\n"
115
+ f"Respond with {instruction} that highlights the most valuable revision to make next."
116
+ ),
117
+ },
118
+ ]
119
+
120
+ try:
121
+ if reviewer._openai_client is not None and hasattr(reviewer._openai_client, "responses"):
122
+ response = reviewer._openai_client.responses.create( # type: ignore[union-attr]
123
+ model=reviewer.openai_model,
124
+ input=messages,
125
+ max_output_tokens=256,
126
+ )
127
+ content = getattr(response, "output_text", None)
128
+ if not content and hasattr(response, "outputs"):
129
+ # Legacy compatibility: join content parts
130
+ parts = []
131
+ for item in getattr(response, "outputs", []):
132
+ parts.extend(getattr(item, "content", []))
133
+ content = " ".join(getattr(part, "text", "") for part in parts)
134
+ elif hasattr(openai, "ChatCompletion"):
135
+ chat_response = openai.ChatCompletion.create( # type: ignore[attr-defined]
136
+ model=reviewer.openai_model,
137
+ messages=messages,
138
+ temperature=0.3,
139
+ max_tokens=256,
140
+ )
141
+ content = (
142
+ chat_response["choices"][0]["message"]["content"].strip() # type: ignore[index, assignment]
143
+ )
144
+ else:
145
+ content = None
146
+ except Exception as exc: # pragma: no cover - network/API failure
147
+ log_event("openai_feedback_failed", error=str(exc))
148
+ content = None
149
+ if content:
150
+ content = content.strip()
151
+ if content:
152
+ return content
153
+ return None
58
154
 
155
+
156
+ def _local_feedback(
157
+ reviewer: Reviewer,
158
+ text: str,
159
+ references: list[Any],
160
+ user: User,
161
+ ) -> str | None:
59
162
  payload = {
60
163
  "document": text,
61
164
  "references": [getattr(ref, "content", "") for ref in references],
62
- "length_instruction": {
63
- "Brief": "1–2 short sentences",
64
- "Detailed": "A couple short paragraphs",
65
- "Verbose": "As thorough as reasonably possible without repeating information",
66
- }.get(user.preferred_feedback_length, "1–2 short sentences"),
165
+ "length_instruction": reviewer.length_map.get(
166
+ user.preferred_feedback_length,
167
+ "1–2 short sentences",
168
+ ),
67
169
  }
68
170
 
69
171
  try:
70
172
  response = requests.post(reviewer.endpoint, json=payload, timeout=30)
71
173
  response.raise_for_status()
72
174
  data = response.json()
73
- feedback = data.get("feedback")
175
+ feedback = data.get("feedback") or data.get("text")
74
176
  if feedback:
75
- return feedback
177
+ return str(feedback).strip()
76
178
  except Exception as exc: # pragma: no cover - network failures stay graceful
77
179
  log_event("local_feedback_failed", error=str(exc))
78
180
 
181
+ return None
182
+
183
+
184
+ def get_feedback(
185
+ reviewer: Reviewer,
186
+ doc: Document,
187
+ text: str,
188
+ references: list[Any],
189
+ user: User,
190
+ ) -> str:
191
+ if reviewer.is_cloud and cloud_get_feedback is not None:
192
+ return cloud_get_feedback(reviewer._cloud_impl, doc, text, references, user) # type: ignore[arg-type]
193
+
194
+ if reviewer.provider == "openai":
195
+ feedback = _openai_feedback(reviewer, doc, text, references, user)
196
+ if feedback:
197
+ return feedback
198
+
199
+ if reviewer.provider == "local" and getattr(reviewer, "endpoint", None):
200
+ feedback = _local_feedback(reviewer, text, references, user)
201
+ if feedback:
202
+ return feedback
203
+
79
204
  return _fallback_feedback(text, references)
@@ -76,9 +76,13 @@ def _embedding_column():
76
76
  raise RuntimeError(
77
77
  "pgvector is required when COMPAIR_VECTOR_BACKEND is set to 'pgvector'."
78
78
  )
79
- return mapped_column(Vector(EMBEDDING_DIMENSION), nullable=True)
79
+ return mapped_column(
80
+ Vector(EMBEDDING_DIMENSION),
81
+ nullable=True,
82
+ default=None,
83
+ )
80
84
  # Store embeddings as JSON arrays (works across SQLite/Postgres without pgvector)
81
- return mapped_column(JSON, nullable=True)
85
+ return mapped_column(JSON, nullable=True, default=None)
82
86
 
83
87
 
84
88
  def cosine_similarity(vec1: Sequence[float] | None, vec2: Sequence[float] | None) -> float | None:
@@ -279,10 +283,10 @@ class Document(BaseObject):
279
283
  doc_type: Mapped[str]
280
284
  datetime_created: Mapped[datetime]
281
285
  datetime_modified: Mapped[datetime]
286
+ embedding: Mapped[list[float] | None] = _embedding_column()
282
287
  file_key: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
283
288
  image_key: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
284
289
  is_published: Mapped[bool] = mapped_column(Boolean, default=False)
285
- embedding: Mapped[list[float] | None] = _embedding_column()
286
290
 
287
291
  user = relationship("User", back_populates="documents")
288
292
  groups = relationship("Group", secondary="document_to_group", back_populates="documents")
@@ -315,8 +319,8 @@ class Note(Base):
315
319
  author_id: Mapped[str] = mapped_column(ForeignKey("user.user_id", ondelete="CASCADE"), index=True)
316
320
  group_id: Mapped[str | None] = mapped_column(ForeignKey("group.group_id", ondelete="CASCADE"), index=True, nullable=True)
317
321
  content: Mapped[str] = mapped_column(Text)
318
- datetime_created: Mapped[datetime] = mapped_column(default=datetime.now(timezone.utc))
319
322
  embedding: Mapped[list[float] | None] = _embedding_column()
323
+ datetime_created: Mapped[datetime] = mapped_column(default=datetime.now(timezone.utc))
320
324
 
321
325
  document = relationship("Document", back_populates="notes")
322
326
  author = relationship("User", back_populates="notes")
@@ -46,13 +46,19 @@ class EmbedResponse(BaseModel):
46
46
 
47
47
 
48
48
  class GenerateRequest(BaseModel):
49
+ # Legacy format used by the CLI shim
49
50
  system: str | None = None
50
- prompt: str
51
+ prompt: str | None = None
51
52
  verbosity: str | None = None
52
53
 
54
+ # Core API payload (document + references)
55
+ document: str | None = None
56
+ references: List[str] | None = None
57
+ length_instruction: str | None = None
58
+
53
59
 
54
60
  class GenerateResponse(BaseModel):
55
- text: str
61
+ feedback: str
56
62
 
57
63
 
58
64
  @app.post("/embed", response_model=EmbedResponse)
@@ -62,12 +68,20 @@ def embed(request: EmbedRequest) -> EmbedResponse:
62
68
 
63
69
  @app.post("/generate", response_model=GenerateResponse)
64
70
  def generate(request: GenerateRequest) -> GenerateResponse:
65
- prompt = request.prompt.strip()
66
- if not prompt:
67
- return GenerateResponse(text="NONE")
68
-
69
- first_sentence = prompt.split("\n", 1)[0][:200]
70
- verbosity = request.verbosity or "default"
71
- return GenerateResponse(
72
- text=f"[local-{verbosity}] Key takeaway: {first_sentence}"
73
- )
71
+ # Determine the main text input (document or prompt)
72
+ text_input = request.document or request.prompt or ""
73
+ text_input = text_input.strip()
74
+
75
+ if not text_input:
76
+ return GenerateResponse(feedback="NONE")
77
+
78
+ first_sentence = text_input.split("\n", 1)[0][:200]
79
+ verbosity = request.length_instruction or request.verbosity or "brief response"
80
+ ref_snippet = ""
81
+ if request.references:
82
+ top_ref = (request.references[0] or "").strip()
83
+ if top_ref:
84
+ ref_snippet = f" Reference: {top_ref[:160]}"
85
+
86
+ feedback = f"[local-feedback] {verbosity}: {first_sentence}{ref_snippet}".strip()
87
+ return GenerateResponse(feedback=feedback or "NONE")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compair-core
3
- Version: 0.3.14
3
+ Version: 0.3.15
4
4
  Summary: Open-source foundation of the Compair collaboration platform.
5
5
  Author: RocketResearch, Inc.
6
6
  License: MIT
@@ -94,6 +94,8 @@ Key environment variables for the core edition:
94
94
  - `COMPAIR_INCLUDE_LEGACY_ROUTES` (`false`) – opt-in to the full legacy API surface (used by the hosted product) when running the core edition. Leave unset to expose only the streamlined single-user endpoints in Swagger.
95
95
  - `COMPAIR_EMBEDDING_DIM` – force the embedding vector size stored in the database (defaults to 384 for core, 1536 for cloud). Keep this in sync with whichever embedding model you configure.
96
96
  - `COMPAIR_VECTOR_BACKEND` (`auto`) – set to `pgvector` when running against PostgreSQL with the pgvector extension, or `json` to store embeddings as JSON (the default for SQLite deployments).
97
+ - `COMPAIR_GENERATION_PROVIDER` (`local`) – choose how feedback is produced. Options: `local` (call the bundled FastAPI service), `openai` (use ChatGPT-compatible APIs with an API key), or `fallback` (skip generation and surface similar references only).
98
+ - `COMPAIR_OPENAI_API_KEY` / `COMPAIR_OPENAI_MODEL` – when using the OpenAI provider, supply your API key and optional model name (defaults to `gpt-4o-mini`). The fallback kicks in automatically if the key or SDK is unavailable.
97
99
 
98
100
  See `compair_core/server/settings.py` for the full settings surface.
99
101
 
@@ -4,10 +4,10 @@ compair_core/compair/__init__.py,sha256=V2mqe6UQEvY4U8XL8T-TtCRNDWVUMNeCLZ8nsYQL
4
4
  compair_core/compair/celery_app.py,sha256=OM_Saza9yC9Q0kz_WXctfswrKkG7ruT52Zl5E4guiT0,640
5
5
  compair_core/compair/default_groups.py,sha256=dbacrFkSjqEQZ_uoFU5gYhgIoP_3lmvz6LJNHCJvxlw,498
6
6
  compair_core/compair/embeddings.py,sha256=hlDt_C4JWKQCx_pWZZeBSNaF2gvkIUupQyl5Wq4HR34,2754
7
- compair_core/compair/feedback.py,sha256=jgDxYKo5PzW2p-uLf5ETmlL-mDAfqzJazO1NZDK-Z-g,2755
7
+ compair_core/compair/feedback.py,sha256=FvHefla0bKdyWLNduAVjCxASgZAM-XumElht3JtWHPs,7573
8
8
  compair_core/compair/logger.py,sha256=mB9gV3FfC0qE_G9NBErpEAJhyGxDxEVKqWKu3n8hOkc,802
9
9
  compair_core/compair/main.py,sha256=d2d7ENXDWKVG_anOG8Kkr-r9W19h__7zwXSo-3zx4rY,8976
10
- compair_core/compair/models.py,sha256=Bepyo7Vyh9serzVsE0-s0HXo1oKDNem6kV-DnqyeFwU,16515
10
+ compair_core/compair/models.py,sha256=0ysM2HG1E8j4MmW6P7abEgNFfor_yfFffScYIl8xku8,16590
11
11
  compair_core/compair/schema.py,sha256=TxQpDQ96J_tIj-Y_1C_x2eUYn9n_LOG6XiNLCX1-GYY,2902
12
12
  compair_core/compair/tasks.py,sha256=CY-Oo9UeqNF52s2twUkXSyHGgz1_PX8ffTqxmmIlDJs,3864
13
13
  compair_core/compair/utils.py,sha256=7bbkRb-9zmPAogqR7zQHLnQUkoeW9bwhqPz2S5FRKSc,978
@@ -21,7 +21,7 @@ compair_core/server/app.py,sha256=4FY4Pur1fJUkQAwBApLhPQsnF6BRNZjUQ-X7bJCv2mI,36
21
21
  compair_core/server/deps.py,sha256=0X-Z5JQGeXwbMooWIOC2kXVmsiJIvgUtqkK2PmDjKpI,1557
22
22
  compair_core/server/settings.py,sha256=mWE5vgIx3jxm6LzyeH6QL1tCwxQ6bsZIQffVowqtkFQ,1681
23
23
  compair_core/server/local_model/__init__.py,sha256=YlzDgorgAjGou9d_W29Xp3TVu08e4t9x8csFxn8cgSE,50
24
- compair_core/server/local_model/app.py,sha256=N0cb1BdBQbp2TIwMeh4O4ttdgF7k-uHoURnI_X8HPFE,1943
24
+ compair_core/server/local_model/app.py,sha256=943dnixGcaxHixBXVgJmG5G69gisFcwcIh65NJ5scGU,2552
25
25
  compair_core/server/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
26
  compair_core/server/providers/console_mailer.py,sha256=7ady954yOPlT8t3spkDvdMdO3BTwosJUj1cpVNOwj8U,308
27
27
  compair_core/server/providers/contracts.py,sha256=pYA_2AaPHw089O_UP2JtWRHbIiVkoNGhLuesuNATowU,1858
@@ -31,8 +31,8 @@ compair_core/server/providers/noop_billing.py,sha256=V18Cpl1D1reM3xhgw-lShGliVpY
31
31
  compair_core/server/providers/noop_ocr.py,sha256=fMaJrivDef38-ECgIuTXUBCIm_avgvZf3nQ3UTdFPNI,341
32
32
  compair_core/server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  compair_core/server/routers/capabilities.py,sha256=2U9lEzyQRRYftprrvEeM5Lif_5rhiRGqZhIsvYZBaE4,1349
34
- compair_core-0.3.14.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
35
- compair_core-0.3.14.dist-info/METADATA,sha256=V6ofQ2FtQI3SnhcnseQeP3DNx_HiUNx-8yD4vzpTQvA,5703
36
- compair_core-0.3.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
37
- compair_core-0.3.14.dist-info/top_level.txt,sha256=1dpwoLSY2DWQUVGS05Tq0MuFXg8sabYzg4V2deLzzuo,13
38
- compair_core-0.3.14.dist-info/RECORD,,
34
+ compair_core-0.3.15.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
35
+ compair_core-0.3.15.dist-info/METADATA,sha256=GPBU-Wg4GLnY3isvgl4z9JTtTQs-iKyneD_rTMNB7hg,6191
36
+ compair_core-0.3.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
37
+ compair_core-0.3.15.dist-info/top_level.txt,sha256=1dpwoLSY2DWQUVGS05Tq0MuFXg8sabYzg4V2deLzzuo,13
38
+ compair_core-0.3.15.dist-info/RECORD,,