File size: 10,931 Bytes
929c38d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import os
from typing import Optional, List

import streamlit as st
from dotenv import load_dotenv


def load_env() -> None:
    """Load environment variables from a local .env file if present."""
    try:
        load_dotenv()
    except Exception:
        pass


def read_text_from_file(uploaded_file) -> str:
    """Read text from a Streamlit uploaded file (.txt or .pdf)."""
    if uploaded_file is None:
        return ""

    filename = uploaded_file.name.lower()
    if filename.endswith(".txt"):
        raw_bytes = uploaded_file.read()
        try:
            return raw_bytes.decode("utf-8")
        except Exception:
            return raw_bytes.decode("latin-1", errors="ignore")

    if filename.endswith(".pdf"):
        try:
            from pypdf import PdfReader
        except Exception as exc:
            st.error("Для чтения PDF требуется зависимость 'pypdf'. Добавьте её в окружение.")
            raise exc

        reader = PdfReader(uploaded_file)
        pages_text: List[str] = []
        for page in reader.pages:
            try:
                pages_text.append(page.extract_text() or "")
            except Exception:
                pages_text.append("")
        return "\n\n".join(pages_text)

    st.warning("Поддерживаются только файлы .txt и .pdf")
    return ""


def make_llm(provider: str, model: str, api_key: Optional[str], temperature: float = 0.2):
    """Create an LLM instance for the chosen provider."""
    if provider == "OpenAI":
        try:
            from langchain_openai import ChatOpenAI
        except Exception as exc:
            st.error("Не найдена библиотека 'langchain-openai'. Установите зависимости из requirements.txt")
            raise exc

        effective_key = api_key or os.getenv("OPENAI_API_KEY")
        if not effective_key:
            st.stop()
        return ChatOpenAI(model=model, api_key=effective_key, temperature=temperature)

    if provider == "Ollama":
        try:
            from langchain_ollama import ChatOllama
        except Exception as exc:
            st.error("Не найдена библиотека 'langchain-ollama'. Установите зависимости из requirements.txt")
            raise exc

        base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
        return ChatOllama(model=model, base_url=base_url, temperature=temperature)

    raise ValueError(f"Unknown provider: {provider}")


def chunk_text(text: str, chunk_size: int = 2000, chunk_overlap: int = 200) -> List[str]:
    """Split long text into chunks using LangChain's RecursiveCharacterTextSplitter if available.

    Fallback to a simple splitter by characters.

    """
    text = (text or "").strip()
    if not text:
        return []

    try:
        from langchain.text_splitter import RecursiveCharacterTextSplitter

        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=["\n\n", "\n", ". ", ".", "? ", "! ", ", ", ",", " "]
        )
        docs = splitter.create_documents([text])
        return [d.page_content for d in docs]
    except Exception:
        # Simple fallback: naive split by characters with safe stepping
        chunks: List[str] = []
        n = len(text)
        safe_overlap = max(0, min(chunk_overlap, chunk_size - 1))
        step = max(1, chunk_size - safe_overlap)
        i = 0
        while i < n:
            end = min(i + chunk_size, n)
            chunks.append(text[i:end])
            if end >= n:
                break
            i += step
        return chunks


def build_chunk_prompt(

    chunk: str,

    target_length: str,

    bullet_points: bool,

    language_pref: str,

) -> str:
    """Prompt to summarize a single chunk."""
    formatting = (
        "Сформируй маркированный список из 5-10 пунктов" if bullet_points else "Сформируй связный абзац из 5-8 предложений"
    )
    language_instruction = (
        "Ответь на том же языке, что и входной текст." if language_pref == "Авто" else f"Ответь на {language_pref}."
    )

    return (
        f"Ты — эксперт по конспектированию. Сожми следующий текст в {target_length} конспект для занятого читателя.\n\n"
        f"Требования:\n"
        f"- {formatting}\n"
        f"- {language_instruction}\n"
        f"- Сохраняй ключевые факты, цифры, имена, причинно-следственные связи\n"
        f"- Избегай воды и повторов, не придумывай новых фактов\n\n"
        f"Текст:\n{chunk}\n"
    )


def build_combine_prompt(

    partial_summaries: str,

    target_length: str,

    bullet_points: bool,

    language_pref: str,

) -> str:
    formatting = (
        "Сформируй маркированный список из 5-12 пунктов" if bullet_points else "Сформируй связный абзац(ы) из 8-15 предложений"
    )
    language_instruction = (
        "Ответь на том же языке, что и входной текст." if language_pref == "Авто" else f"Ответь на {language_pref}."
    )

    return (
        f"Ты — эксперт по сжатию информации. Объедини частичные конспекты ниже в один цельный {target_length} конспект.\n\n"
        f"Требования:\n"
        f"- {formatting}\n"
        f"- {language_instruction}\n"
        f"- Сохраняй структуру и ключевые факты без повтора\n\n"
        f"Частичные конспекты:\n{partial_summaries}\n"
    )


def call_llm(llm, prompt: str) -> str:
    """Call the chat model with a system+user style prompt packed into a single user message."""
    try:
        # Many LangChain chat models accept plain strings via .invoke
        result = llm.invoke(prompt)
        # For Chat models, content is on .content
        content = getattr(result, "content", None)
        return content if isinstance(content, str) and content.strip() else (str(result) if result else "")
    except Exception as exc:
        st.error(f"Ошибка вызова LLM: {exc}")
        raise


def summarize_long_text(

    llm,

    text: str,

    target_length: str,

    bullet_points: bool,

    language_pref: str,

) -> str:
    """Chunk the text, summarize each chunk, then combine."""
    chunks = chunk_text(text)
    if not chunks:
        return ""

    if len(chunks) == 1:
        single_prompt = build_chunk_prompt(chunks[0], target_length, bullet_points, language_pref)
        return call_llm(llm, single_prompt)

    partials: List[str] = []
    for idx, ch in enumerate(chunks, start=1):
        with st.spinner(f"Суммаризация фрагмента {idx}/{len(chunks)}…"):
            partials.append(call_llm(llm, build_chunk_prompt(ch, target_length, bullet_points, language_pref)))

    combined_prompt = build_combine_prompt("\n\n".join(partials), target_length, bullet_points, language_pref)
    return call_llm(llm, combined_prompt)


def main():
    load_env()

    st.set_page_config(page_title="AI‑Конспектор", page_icon="📝", layout="centered")
    st.title("📝 AI‑конспектор текста")
    st.caption("Python + LangChain + OpenAI/Ollama + Streamlit")

    with st.sidebar:
        st.header("Настройки")
        provider = st.selectbox("Провайдер", ["OpenAI", "Ollama"], index=0)

        if provider == "OpenAI":
            default_model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
            model = st.selectbox("Модель (OpenAI)", ["gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"], index=0)
            api_key = st.text_input("OPENAI_API_KEY", value=os.getenv("OPENAI_API_KEY", ""), type="password")
        else:
            default_model = os.getenv("OLLAMA_MODEL", "llama2")
            model = st.text_input("Модель (Ollama)", value=default_model, help="Например: llama2, llama3, mistral")
            api_key = None

        target_length = st.radio(
            "Длина конспекта",
            options=["Короткий", "Средний", "Длинный"],
            index=1,
            help="Короткий ≈ 3–5 пунктов, Средний ≈ 6–10, Длинный ≈ 10–15"
        )
        bullet_points = st.toggle("Маркированные пункты", value=True)
        language_pref = st.selectbox("Язык вывода", ["Авто", "Русский", "English"], index=0)

    st.subheader("Входные данные")
    tab_text, tab_file = st.tabs(["Вставить текст", "Загрузить файл (.txt/.pdf)"])

    with tab_text:
        input_text = st.text_area(
            "Текст для конспекта",
            height=240,
            placeholder="Вставьте или напишите сюда длинный текст…",
        ).strip()

    with tab_file:
        uploaded = st.file_uploader("Выберите файл", type=["txt", "pdf"], accept_multiple_files=False)
        if uploaded is not None and not input_text:
            input_text = read_text_from_file(uploaded)

    if st.button("Сжать текст"):
        if not input_text:
            st.warning("Введите текст или загрузите файл.")
            st.stop()

        with st.spinner("Подготавливаем модель…"):
            llm = make_llm(provider=provider, model=model, api_key=api_key, temperature=0.2)

        with st.spinner("Генерируем конспект…"):
            summary = summarize_long_text(
                llm=llm,
                text=input_text,
                target_length=target_length,
                bullet_points=bullet_points,
                language_pref=language_pref,
            )

        if summary:
            st.success("Готово!")
            st.subheader("Результат")
            st.write(summary)
            st.download_button("⬇️ Скачать как TXT", data=summary, file_name="summary.txt")
        else:
            st.error("Не удалось получить конспект. Попробуйте ещё раз или измените настройки.")


if __name__ == "__main__":
    main()